Compare commits
55 Commits
ea2b813728
...
feat/json-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2aff0e414 | ||
|
|
4072abf7ba | ||
|
|
089ca43404 | ||
|
|
f09c7e4782 | ||
|
|
6a20dcce54 | ||
|
|
6e0be3dbf3 | ||
|
|
f66db3f2f0 | ||
|
|
0864af1439 | ||
|
|
5210e53d73 | ||
|
|
3f07162b94 | ||
|
|
57615b3e9d | ||
|
|
46694d11d9 | ||
|
|
44cfa25eca | ||
|
|
7ea4cc8c12 | ||
|
|
bb300a7ca7 | ||
|
|
556da6e451 | ||
|
|
8871440c9a | ||
|
|
6f1756e82e | ||
|
|
55bed90ac7 | ||
|
|
a6139d7090 | ||
|
|
8ed5f90b63 | ||
|
|
5194543d16 | ||
|
|
c01b71fe06 | ||
|
|
5336dfc09d | ||
|
|
77c5d25cea | ||
|
|
e2326064ba | ||
|
|
100e24725c | ||
|
|
515bae189e | ||
|
|
333f2a88af | ||
|
|
eccbc1bd56 | ||
|
|
2a0809a065 | ||
|
|
f2061abce8 | ||
|
|
42c7072bcd | ||
|
|
1f90f809ac | ||
|
|
a940f53f8a | ||
|
|
c74bdedf9b | ||
|
|
233ee3faf3 | ||
|
|
b8edf1ea95 | ||
|
|
7a7af58074 | ||
|
|
03e6c2432b | ||
|
|
31408ded7f | ||
|
|
4054fb24e6 | ||
|
|
32ba4928df | ||
| edf7d0fa9e | |||
| 233927df19 | |||
| dcb5f15769 | |||
| d3cd3fc3ce | |||
| 33fc80cbc2 | |||
| 33e3f25850 | |||
| efc6ec5691 | |||
| b342d0e50a | |||
| 0709d01240 | |||
| 74f77a3ba8 | |||
| bab13e5c57 | |||
|
|
378026ebce |
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>
|
||||
77
CLAUDE.md
77
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)
|
||||
@@ -63,6 +64,14 @@ npm run build # Build production
|
||||
npm run lint:fix # ESLint fix
|
||||
npx nuxi typecheck # TypeScript check (0 errors attendu)
|
||||
|
||||
# Database / Fixtures
|
||||
make db-reset # Reset database (drop + recreate schema)
|
||||
make fixtures-dump # Dump la DB vers fixtures/data.sql
|
||||
make fixtures-load # Charger les fixtures SQL (désactive FK)
|
||||
make fixtures-reset # Reset DB + recharger fixtures
|
||||
make import-data # Importer les dumps SQL normalisés
|
||||
make cache-clear # Clear cache Symfony
|
||||
|
||||
# Release
|
||||
./scripts/release.sh patch # Bump patch version (ou minor/major)
|
||||
```
|
||||
@@ -98,7 +107,12 @@ 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`
|
||||
|
||||
#### Entités de normalisation (slots & skeleton requirements)
|
||||
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
|
||||
- **Slots** (données réelles d'un composant) : `ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot`
|
||||
- **Skeleton Requirements** (définitions du ModelType) : `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
|
||||
|
||||
### Patterns
|
||||
- **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
|
||||
@@ -108,6 +122,33 @@ 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), `/api/machines/{id}/clone` (POST) : hiérarchie complète machine avec normalisation JSON manuelle. 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.
|
||||
- `ComposantPieceSlotController` — `/api/composant-piece-slots/{id}` (PATCH) : mise à jour des slots pièce d'un composant.
|
||||
- `SessionProfileController` — `/api/session/profile` (GET/POST/DELETE) : auth session (login/logout/current user).
|
||||
- `SessionProfilesController` — `/api/session/profiles` (GET) : liste des profils disponibles pour la session.
|
||||
- `AdminProfileController` — `/api/admin/profiles` : CRUD profils, gestion rôles et mots de passe (ROLE_ADMIN).
|
||||
- `CommentController` — `/api/comments` : création, résolution, compteur non-résolus.
|
||||
- `ActivityLogController` — `/api/activity-logs` (GET) : journal d'activité global.
|
||||
- `EntityHistoryController` — `/api/{entity}/{id}/history` (GET) : historique audit par entité (machines, pièces, composants, produits).
|
||||
- `DocumentQueryController` — `/api/documents/{entity}/{id}` (GET) : documents par site/machine/composant/pièce/produit.
|
||||
- `DocumentServeController` — `/api/documents/{id}/file|download` (GET) : servir/télécharger fichiers.
|
||||
- `ModelTypeConversionController` — `/api/model_types/{id}/conversion-check|convert` : vérification et conversion de ModelType.
|
||||
- `HealthCheckController` — `/api/health` (GET) : health check.
|
||||
|
||||
### Custom Fields — Architecture
|
||||
- **Composants/Pièces/Produits** : définitions dans les entités `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` du ModelType (anciennement JSON `structure`, normalisé en tables relationnelles). Les custom fields de ces entités sont définis dans `customFields` JSON sur chaque Skeleton*Requirement.
|
||||
- **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
|
||||
|
||||
### Normalisation JSON → Tables (architecture slots)
|
||||
Les anciennes colonnes JSON `structure` et `productIds` des Composants ont été remplacées par des tables relationnelles :
|
||||
- **ModelType** définit le squelette via `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
|
||||
- **Composant** stocke les données réelles via `ComposantPieceSlot`, `ComposantProductSlot`, `ComposantSubcomponentSlot`
|
||||
- Chaque slot référence son skeleton requirement (`skeletonRequirement` FK) + l'entité sélectionnée + position
|
||||
|
||||
### Rôles (hiérarchie)
|
||||
```
|
||||
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||
@@ -138,13 +179,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 +207,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()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`
|
||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
|
||||
|
||||
## 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: e732585e63...271844efb1
269
README.md
269
README.md
@@ -2,52 +2,87 @@
|
||||
|
||||
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,6 @@ 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 }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
|
||||
|
||||
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
@@ -1385,7 +1387,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* mercure?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* hub_url?: scalar|null|Param, // The URL sent in the Link HTTP header. If not set, will default to the URL for MercureBundle's default hub. // Default: null
|
||||
* include_type?: bool|Param, // Always include @type in updates (including delete ones). // Default: false
|
||||
* include_type?: bool|Param, // Always include @var in updates (including delete ones). // Default: false
|
||||
* },
|
||||
* messenger?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
@@ -1606,6 +1608,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* cache?: scalar|null|Param, // Storage to track blocked tokens // Default: "cache.app"
|
||||
* },
|
||||
* }
|
||||
* @psalm-type DamaDoctrineTestConfig = array{
|
||||
* enable_static_connection?: mixed, // Default: true
|
||||
* enable_static_meta_data_cache?: bool|Param, // Default: true
|
||||
* enable_static_query_cache?: bool|Param, // Default: true
|
||||
* connection_keys?: list<mixed>,
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1656,6 +1664,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* nelmio_cors?: NelmioCorsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* dama_doctrine_test?: DamaDoctrineTestConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
|
||||
@@ -33,3 +33,30 @@ services:
|
||||
App\EventSubscriber\ComposantAuditSubscriber:
|
||||
tags:
|
||||
- { name: doctrine.event_subscriber }
|
||||
|
||||
App\OpenApi\OpenApiDecorator:
|
||||
decorates: 'api_platform.openapi.factory'
|
||||
arguments:
|
||||
$decorated: '@.inner'
|
||||
|
||||
when@test:
|
||||
services:
|
||||
App\Service\Sync\ProductSyncStrategy:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\Sync\ComposantSyncStrategy:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\Sync\PieceSyncStrategy:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\ModelTypeSyncService:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
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
1067
docs/superpowers/plans/2026-03-12-json-to-tables-normalization.md
Normal file
1067
docs/superpowers/plans/2026-03-12-json-to-tables-normalization.md
Normal file
File diff suppressed because it is too large
Load Diff
546
docs/superpowers/plans/2026-03-12-piece-quantity.md
Normal file
546
docs/superpowers/plans/2026-03-12-piece-quantity.md
Normal file
@@ -0,0 +1,546 @@
|
||||
# Piece Quantity Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a quantity field to pieces — on `MachinePieceLink` for machine-direct pieces, and in `Composant.structure.pieces[]` JSON for composant pieces.
|
||||
|
||||
**Architecture:** Quantity lives on the relationship, not the catalogue entity. For machine-direct pieces, a new `quantity` integer column on `MachinePieceLink` (default 1). For composant pieces, a `quantity` key in the existing `structure.pieces[]` JSON (default 1). Display: "×N" after piece name, hidden when N=1.
|
||||
|
||||
**Tech Stack:** Symfony 8 / API Platform, Doctrine ORM, PostgreSQL, Nuxt 4, Vue 3 Composition API, TypeScript, DaisyUI 5
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-12-piece-quantity-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Backend
|
||||
|
||||
### Task 1: Entity + Migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/MachinePieceLink.php`
|
||||
- Existing: `migrations/Version20260309150000.php` (already written, untracked)
|
||||
|
||||
- [ ] **Step 1: Add quantity field to MachinePieceLink entity**
|
||||
|
||||
In `src/Entity/MachinePieceLink.php`, add after the `prixOverride` field (line 69):
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Assert\GreaterThanOrEqual(1)]
|
||||
private int $quantity = 1;
|
||||
```
|
||||
|
||||
Add the import at the top if not present:
|
||||
```php
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
```
|
||||
|
||||
Add getter/setter after existing methods (before closing brace):
|
||||
|
||||
```php
|
||||
public function getQuantity(): int
|
||||
{
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function setQuantity(int $quantity): static
|
||||
{
|
||||
$this->quantity = $quantity;
|
||||
|
||||
return $this;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Stage the migration file**
|
||||
|
||||
The migration `migrations/Version20260309150000.php` already exists (untracked). Verify its content matches:
|
||||
|
||||
```php
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE machine_piece_links ADD COLUMN IF NOT EXISTS quantity INTEGER NOT NULL DEFAULT 1');
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run php-cs-fixer**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
|
||||
- [ ] **Step 4: Run tests to verify nothing is broken**
|
||||
|
||||
Run: `make test`
|
||||
Expected: All 167 tests pass (OK, with possible deprecation warnings)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Entity/MachinePieceLink.php migrations/Version20260309150000.php
|
||||
git commit -m "feat(piece) : add quantity field to MachinePieceLink entity + migration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: MachineStructureController — Normalization + PATCH + Clone
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Controller/MachineStructureController.php`
|
||||
|
||||
- [ ] **Step 1: Add quantity to `normalizePieceLinks()`**
|
||||
|
||||
In `src/Controller/MachineStructureController.php`, method `normalizePieceLinks()` (line ~623-641).
|
||||
|
||||
Add `'quantity'` to the returned array, after `'overrides'`:
|
||||
|
||||
```php
|
||||
'quantity' => $this->resolvePieceQuantity($link),
|
||||
```
|
||||
|
||||
Add a new private method after `normalizePieceLinks()`:
|
||||
|
||||
```php
|
||||
private function resolvePieceQuantity(MachinePieceLink $link): int
|
||||
{
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
if (!$parentLink) {
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
$composant = $parentLink->getComposant();
|
||||
$structure = $composant->getStructure();
|
||||
|
||||
if (!is_array($structure) || !isset($structure['pieces']) || !is_array($structure['pieces'])) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$piece = $link->getPiece();
|
||||
$typePiece = $piece->getTypePiece();
|
||||
$typePieceId = $typePiece?->getId();
|
||||
|
||||
foreach ($structure['pieces'] as $pieceDef) {
|
||||
if (!is_array($pieceDef)) {
|
||||
continue;
|
||||
}
|
||||
if (isset($pieceDef['typePieceId']) && $pieceDef['typePieceId'] === $typePieceId) {
|
||||
return (int) ($pieceDef['quantity'] ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Matching is done by `typePieceId`. If a composant has two pieces of the same type, they will get the same quantity (first match). This is an acceptable limitation for now — duplicates of the same piece type in a composant are rare.
|
||||
|
||||
- [ ] **Step 2: Apply quantity in `applyPieceLinks()`**
|
||||
|
||||
In method `applyPieceLinks()` (line ~366-422), add quantity application after `$this->applyOverrides($link, $entry['overrides'] ?? null);` (line ~396):
|
||||
|
||||
```php
|
||||
if (!isset($entry['parentComponentLinkId']) && !isset($entry['parentLinkId'])) {
|
||||
$quantity = isset($entry['quantity']) ? (int) $entry['quantity'] : $link->getQuantity();
|
||||
$link->setQuantity(max(1, $quantity));
|
||||
}
|
||||
```
|
||||
|
||||
**Key behavior:**
|
||||
- Only applies to direct machine pieces (no parent component link)
|
||||
- If `quantity` not in payload: preserves existing value
|
||||
- If `quantity` in payload: sets it, with floor of 1
|
||||
- For composant-child pieces: quantity is ignored (comes from composant structure)
|
||||
|
||||
- [ ] **Step 3: Copy quantity in `clonePieceLinks()`**
|
||||
|
||||
In method `clonePieceLinks()` (line ~233-256), add after `$newLink->setPrixOverride($link->getPrixOverride());` (line ~244):
|
||||
|
||||
```php
|
||||
$newLink->setQuantity($link->getQuantity());
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run php-cs-fixer**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
|
||||
- [ ] **Step 5: Run tests**
|
||||
|
||||
Run: `make test`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Controller/MachineStructureController.php
|
||||
git commit -m "feat(piece) : add quantity to structure normalization, PATCH and clone"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Backend Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/Api/Entity/MachinePieceLinkTest.php`
|
||||
- Modify: `tests/AbstractApiTestCase.php` (factory method)
|
||||
|
||||
- [ ] **Step 1: Update factory method to support quantity**
|
||||
|
||||
In `tests/AbstractApiTestCase.php`, update `createMachinePieceLink()` to accept an optional quantity parameter:
|
||||
|
||||
```php
|
||||
protected function createMachinePieceLink(Machine $machine, Piece $piece, ?MachineComponentLink $parentLink = null, int $quantity = 1): MachinePieceLink
|
||||
{
|
||||
$link = new MachinePieceLink();
|
||||
$link->setMachine($machine);
|
||||
$link->setPiece($piece);
|
||||
$link->setQuantity($quantity);
|
||||
if (null !== $parentLink) {
|
||||
$link->setParentLink($parentLink);
|
||||
}
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($link);
|
||||
$em->flush();
|
||||
|
||||
return $link;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add test for POST with explicit quantity**
|
||||
|
||||
In `tests/Api/Entity/MachinePieceLinkTest.php`, add. Follow the existing test pattern — use `$this->assertJsonContains()` and include the `headers` key with `Content-Type`:
|
||||
|
||||
```php
|
||||
public function testPostWithQuantity(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
$machine = $this->createMachine();
|
||||
$piece = $this->createPiece();
|
||||
|
||||
$client->request('POST', '/api/machine_piece_links', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'machine' => '/api/machines/' . $machine->getId(),
|
||||
'piece' => '/api/pieces/' . $piece->getId(),
|
||||
'quantity' => 5,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$this->assertJsonContains(['quantity' => 5]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add test for POST without quantity (default = 1)**
|
||||
|
||||
```php
|
||||
public function testPostDefaultQuantity(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
$machine = $this->createMachine();
|
||||
$piece = $this->createPiece();
|
||||
|
||||
$client->request('POST', '/api/machine_piece_links', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'machine' => '/api/machines/' . $machine->getId(),
|
||||
'piece' => '/api/pieces/' . $piece->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$this->assertJsonContains(['quantity' => 1]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `make test`
|
||||
Expected: All tests pass including new ones
|
||||
|
||||
- [ ] **Step 5: Run php-cs-fixer**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/Api/Entity/MachinePieceLinkTest.php tests/AbstractApiTestCase.php
|
||||
git commit -m "test(piece) : add quantity tests for MachinePieceLink"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Frontend
|
||||
|
||||
### Task 4: TypeScript Types + Sanitization + Hydration Functions
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/shared/types/inventory.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/model/componentStructure.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/model/componentStructureSanitize.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/model/componentStructureHydrate.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/utils/structureAssignmentHelpers.ts`
|
||||
|
||||
- [ ] **Step 1: Add `quantity` to `ComponentModelPiece` type**
|
||||
|
||||
In `Inventory_frontend/app/shared/types/inventory.ts`, add `quantity` to the `ComponentModelPiece` interface (after `role`, line ~23):
|
||||
|
||||
```typescript
|
||||
quantity?: number
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `quantity` to `validatePiece()` in same file**
|
||||
|
||||
In `Inventory_frontend/app/shared/types/inventory.ts`, in `validatePiece()` (line ~144-172):
|
||||
|
||||
After `const role = ensureString(value.role)` (line ~161), add:
|
||||
|
||||
```typescript
|
||||
const quantity = typeof value.quantity === 'number' && value.quantity >= 1 ? value.quantity : undefined
|
||||
```
|
||||
|
||||
And in the return object, add after the `role` spread:
|
||||
|
||||
```typescript
|
||||
...(quantity ? { quantity } : {}),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `sanitizePieces()` to preserve quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/model/componentStructureSanitize.ts`, in `sanitizePieces()` (~line 130-188).
|
||||
|
||||
After the existing field extractions, add:
|
||||
|
||||
```typescript
|
||||
const quantity = typeof piece?.quantity === 'number' && piece.quantity >= 1 ? piece.quantity : undefined
|
||||
```
|
||||
|
||||
In the result object construction, add alongside existing fields (follow the `if (field) { result.field = field }` pattern used in this function):
|
||||
|
||||
```typescript
|
||||
if (quantity !== undefined) {
|
||||
result.quantity = quantity
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `normalizeStructureForSave()` to include quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/model/componentStructure.ts`, in `normalizeStructureForSave()` (~lines 164-179), add in the piece payload mapping after the `reference` check:
|
||||
|
||||
```typescript
|
||||
if ((piece as any).quantity !== undefined && (piece as any).quantity >= 1) {
|
||||
payload.quantity = (piece as any).quantity
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Always send quantity when defined (including 1), so the backend always has an explicit value.
|
||||
|
||||
- [ ] **Step 5: Update `hydratePieces()` and `mapComponentPieces()` to preserve quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/model/componentStructureHydrate.ts`:
|
||||
|
||||
In `hydratePieces()` (line ~95-107), add to the mapped object:
|
||||
|
||||
```typescript
|
||||
...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}),
|
||||
```
|
||||
|
||||
In `mapComponentPieces()` (line ~168-179), add to the mapped object:
|
||||
|
||||
```typescript
|
||||
...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}),
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update `sanitizePieceDefinition()` to preserve quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/utils/structureAssignmentHelpers.ts`, in `sanitizePieceDefinition()` (~lines 172-180), add to the `stripNullish()` object:
|
||||
|
||||
```typescript
|
||||
quantity: typeof (definition as any).quantity === 'number' ? (definition as any).quantity : null,
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
git add app/shared/types/inventory.ts app/shared/model/componentStructure.ts app/shared/model/componentStructureSanitize.ts app/shared/model/componentStructureHydrate.ts app/shared/utils/structureAssignmentHelpers.ts
|
||||
git commit -m "feat(piece) : add quantity field to piece types, sanitization and hydration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Composant Structure Editor — Quantity Input
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/StructureNodeEditor.vue` (piece section, lines ~229-299)
|
||||
- Modify: `Inventory_frontend/app/composables/useStructureNodeCrud.ts` (`addPiece()`, lines ~110-118)
|
||||
|
||||
**Context:** `StructureNodeEditor.vue` renders the composant structure editor. The piece section (lines ~236-293) currently shows only a `select` for `typePieceId` and a delete button. The `addPiece()` function in `useStructureNodeCrud.ts` creates new piece entries with default fields.
|
||||
|
||||
- [ ] **Step 1: Add default quantity to `addPiece()`**
|
||||
|
||||
In `Inventory_frontend/app/composables/useStructureNodeCrud.ts`, in `addPiece()` (line ~110-118), add `quantity: 1` to the pushed object:
|
||||
|
||||
```typescript
|
||||
const addPiece = () => {
|
||||
ensureArray('pieces')
|
||||
props.node.pieces!.push({
|
||||
typePieceId: '',
|
||||
typePieceLabel: '',
|
||||
reference: '',
|
||||
familyCode: '',
|
||||
role: '',
|
||||
quantity: 1,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add quantity input in `StructureNodeEditor.vue`**
|
||||
|
||||
In `Inventory_frontend/app/components/StructureNodeEditor.vue`, in the piece item rendering section (inside the `v-for` loop for pieces, line ~256-292), add a quantity input next to the existing piece type `select`. Place it after the select and before the delete button:
|
||||
|
||||
```vue
|
||||
<input
|
||||
v-model.number="piece.quantity"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
placeholder="Qté"
|
||||
class="input input-bordered input-sm md:input-md w-20"
|
||||
@input="piece.quantity = Math.max(1, piece.quantity || 1)"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
git add app/components/StructureNodeEditor.vue app/composables/useStructureNodeCrud.ts
|
||||
git commit -m "feat(piece) : add quantity input to composant structure editor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Machine Detail Page — Display Quantity
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/PieceItem.vue`
|
||||
|
||||
**Context:** `PieceItem.vue` renders each piece in the machine structure view. The piece name is displayed at line ~26 in an `<h3>` tag. Quantity should appear as "×N" after the name, in secondary text. For direct pieces (no parent component), it should be editable. For composant pieces, read-only.
|
||||
|
||||
- [ ] **Step 1: Add quantity display to PieceItem**
|
||||
|
||||
In `Inventory_frontend/app/components/PieceItem.vue`, after the piece name in the `<h3>` tag (line ~26), add the quantity display:
|
||||
|
||||
```vue
|
||||
<span
|
||||
v-if="displayQuantity > 1"
|
||||
class="text-sm font-normal text-base-content/60 ml-1"
|
||||
>
|
||||
×{{ displayQuantity }}
|
||||
</span>
|
||||
```
|
||||
|
||||
Add to the component's setup:
|
||||
|
||||
```typescript
|
||||
const displayQuantity = computed(() => {
|
||||
return props.piece.quantity ?? 1
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add editable quantity for direct machine pieces**
|
||||
|
||||
For pieces directly on a machine (no `parentComponentLinkId`), add an editable quantity input in the piece's edit section, following the pattern of existing override fields (nameOverride, referenceOverride, prixOverride). Place it alongside the overrides form:
|
||||
|
||||
```vue
|
||||
<div v-if="!piece.parentComponentLinkId && isEditMode" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">Quantité</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="pieceData.quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input input-bordered input-sm md:input-md w-24"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
Add `quantity` to the `pieceData` reactive object (line ~270-275):
|
||||
|
||||
```typescript
|
||||
quantity: props.piece.quantity ?? 1,
|
||||
```
|
||||
|
||||
Ensure this value is included in the data emitted when saving (follow the same pattern as `nameOverride`, `referenceOverride`, `prixOverride` in the save/emit logic).
|
||||
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
git add app/components/PieceItem.vue
|
||||
git commit -m "feat(piece) : display and edit quantity on machine piece items"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Submodule Update + Final Verification
|
||||
|
||||
**Files:**
|
||||
- Update submodule pointer in main repo
|
||||
|
||||
- [ ] **Step 1: Push frontend commits**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && git push
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update submodule pointer in main repo**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Inventory
|
||||
git add Inventory_frontend
|
||||
git commit -m "chore(frontend) : update submodule — piece quantity feature"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run all backend tests**
|
||||
|
||||
Run: `make test`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 4: Run migration on dev database**
|
||||
|
||||
```bash
|
||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Manual smoke test**
|
||||
|
||||
1. Open a composant edit page → verify quantity input appears on each piece in structure
|
||||
2. Set quantity to 4, save → reload → verify quantity persisted
|
||||
3. Open a machine with that composant → verify "×4" appears next to piece name (read-only)
|
||||
4. Add a piece directly to a machine → verify quantity input appears in edit mode
|
||||
5. Set quantity to 3, save → verify "×3" appears
|
||||
6. Clone the machine → verify cloned pieces have same quantities
|
||||
1582
docs/superpowers/plans/2026-03-13-modeltype-sync.md
Normal file
1582
docs/superpowers/plans/2026-03-13-modeltype-sync.md
Normal file
File diff suppressed because it is too large
Load Diff
140
docs/superpowers/specs/2026-03-12-piece-quantity-design.md
Normal file
140
docs/superpowers/specs/2026-03-12-piece-quantity-design.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Piece Quantity — Design Spec
|
||||
|
||||
## Context
|
||||
|
||||
L'application gère des machines composées de composants et de pièces. Une même pièce (catalogue) peut apparaître dans plusieurs contextes avec des quantités différentes. La quantité doit être portée par la **relation**, pas par l'entité catalogue.
|
||||
|
||||
## Scope
|
||||
|
||||
- Quantité sur les **pièces directement liées à une machine** (`MachinePieceLink`)
|
||||
- Quantité sur les **pièces d'un composant** (JSON `structure.pieces` du `Composant`)
|
||||
- **Hors scope** : quantité sur `MachineComponentLink`, `MachineProductLink`, override de quantité composant au niveau machine, audit logging
|
||||
|
||||
## Règles métier
|
||||
|
||||
| Contexte | Stockage | Éditable depuis | Visible sur machine |
|
||||
|----------|----------|-----------------|---------------------|
|
||||
| Pièce directement sur machine | `MachinePieceLink.quantity` | Page machine | Oui, éditable |
|
||||
| Pièce d'un composant | `Composant.structure.pieces[].quantity` | Page composant (création + édition) | Oui, lecture seule |
|
||||
|
||||
- Type : entier, valeur par défaut = 1, minimum = 1
|
||||
- Affichage : "×N" après le nom de la pièce, masqué si N = 1
|
||||
- Quantité = 0 n'est pas valide (utiliser la suppression du lien à la place)
|
||||
|
||||
## Backend
|
||||
|
||||
### 1. MachinePieceLink — Nouvelle colonne
|
||||
|
||||
Ajout d'un champ `quantity` sur l'entité `MachinePieceLink` :
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
private int $quantity = 1;
|
||||
```
|
||||
|
||||
Getter/setter standard. **Pas de `#[Groups]`** — cohérent avec les autres champs de l'entité qui n'en déclarent pas (l'entité n'a pas de `normalizationContext`).
|
||||
|
||||
Validation : `#[Assert\GreaterThanOrEqual(1)]`
|
||||
|
||||
### 2. Migration SQL
|
||||
|
||||
```sql
|
||||
ALTER TABLE machine_piece_link ADD COLUMN IF NOT EXISTS quantity INTEGER NOT NULL DEFAULT 1;
|
||||
```
|
||||
|
||||
Idempotente avec `IF NOT EXISTS`.
|
||||
|
||||
### 3. Composant.structure JSON
|
||||
|
||||
Le tableau `pieces` dans le JSON `structure` du Composant accepte une nouvelle clé `quantity`. Pas de migration DB nécessaire — c'est un champ JSON libre.
|
||||
|
||||
Avant :
|
||||
```json
|
||||
{ "typePieceId": "...", "role": "Filtration" }
|
||||
```
|
||||
|
||||
Après :
|
||||
```json
|
||||
{ "typePieceId": "...", "role": "Filtration", "quantity": 4 }
|
||||
```
|
||||
|
||||
Les entrées existantes sans `quantity` sont traitées comme `quantity = 1` (défaut côté frontend et backend).
|
||||
|
||||
### 4. MachineStructureController
|
||||
|
||||
#### Normalisation (GET)
|
||||
|
||||
`normalizePieceLinks()` : inclure `quantity` dans la réponse JSON :
|
||||
- **Pièce machine directe** (parentLink = null) : `quantity` depuis `MachinePieceLink.quantity`
|
||||
- **Pièce sous composant** : `quantity` depuis le `structure.pieces` du composant source. Résolution :
|
||||
1. Naviguer `MachinePieceLink` → `parentLink` (MachineComponentLink) → `composant` → `structure['pieces']`
|
||||
2. Matcher par index de position dans le tableau `pieces` (l'ordre des pièces dans la structure correspond à l'ordre de création des liens)
|
||||
3. Fallback : `quantity = 1` si non trouvé
|
||||
|
||||
#### PATCH structure
|
||||
|
||||
Dans `applyPieceLinks()`, accepter `quantity` au même niveau que `pieceId` dans le payload :
|
||||
|
||||
```json
|
||||
{
|
||||
"pieceLinks": [
|
||||
{ "pieceId": "cl...", "quantity": 4, "overrides": { "nameOverride": "..." } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `quantity` est appliqué uniquement pour les pièces directement sur la machine (pas de `parentComponentLinkId`)
|
||||
- Si `parentComponentLinkId` est présent, `quantity` est **ignoré silencieusement** (la valeur vient du composant)
|
||||
|
||||
#### Clone
|
||||
|
||||
`clonePieceLinks()` doit copier `quantity` depuis le lien source :
|
||||
|
||||
```php
|
||||
$newLink->setQuantity($link->getQuantity());
|
||||
```
|
||||
|
||||
Sans cela, les machines clonées perdraient les quantités (reset à 1).
|
||||
|
||||
### 5. Tests
|
||||
|
||||
Ajouter dans `MachinePieceLinkTest.php` :
|
||||
- POST avec `quantity` explicite → vérifier la valeur
|
||||
- POST sans `quantity` → vérifier défaut = 1
|
||||
- PATCH `quantity` sur pièce directe → vérifier mise à jour
|
||||
- GET structure → vérifier `quantity` dans la réponse normalisée
|
||||
- Clone → vérifier que `quantity` est préservé
|
||||
|
||||
## Frontend
|
||||
|
||||
### 1. Types TypeScript
|
||||
|
||||
Mise à jour de `ComponentModelPiece` dans `shared/types/inventory.ts` — ajout du champ `quantity` :
|
||||
|
||||
```typescript
|
||||
quantity?: number // défaut 1
|
||||
```
|
||||
|
||||
### 2. Fonctions de sanitization/normalisation à mettre à jour
|
||||
|
||||
Ces fonctions énumèrent explicitement les champs à conserver et doivent inclure `quantity` :
|
||||
|
||||
- `normalizeStructureForSave()` dans `shared/model/componentStructure.ts` — inclure `quantity` dans le payload backend des pièces
|
||||
- `sanitizePieceDefinition()` dans `shared/utils/structureAssignmentHelpers.ts` — préserver `quantity`
|
||||
- `sanitizePieces()` dans `shared/model/componentStructureSanitize.ts` — préserver `quantity` dans la sortie
|
||||
- `hydratePieces()` / `mapComponentPieces()` — préserver `quantity` lors de l'hydratation
|
||||
|
||||
### 3. Pages composant (création + édition)
|
||||
|
||||
Dans l'éditeur de structure, chaque pièce du tableau `structure.pieces` affiche un champ input :
|
||||
- Type : `number`, min = 1, step = 1
|
||||
- Valeur par défaut : 1
|
||||
- Style : `input input-bordered input-sm md:input-md` (DaisyUI)
|
||||
- Position : à côté des champs existants (reference, role)
|
||||
|
||||
### 4. Page machine (détail/structure)
|
||||
|
||||
- **Pièce directe** (parentLink = null) : affiche "×N" à côté du nom, quantité éditable (input entier)
|
||||
- **Pièce de composant** : affiche "×N" à côté du nom, lecture seule (pas d'input)
|
||||
- Si quantité = 1 : rien n'est affiché (pas de bruit visuel)
|
||||
- Style du label : texte secondaire (`text-base-content/60` ou classe équivalente)
|
||||
502
docs/superpowers/specs/2026-03-13-modeltype-sync-design.md
Normal file
502
docs/superpowers/specs/2026-03-13-modeltype-sync-design.md
Normal file
@@ -0,0 +1,502 @@
|
||||
# ModelType Sync — Design Spec
|
||||
|
||||
## Objectif
|
||||
|
||||
Quand un ModelType (catégorie) est modifié (structure, custom fields), propager automatiquement les changements à tous les items liés (Composants, Pièces, Produits). L'utilisateur voit un preview de l'impact et confirme avant que la sync ne s'exécute.
|
||||
|
||||
## Décisions
|
||||
|
||||
| Décision | Choix |
|
||||
|----------|-------|
|
||||
| Scope sync | Composants + Pièces + Produits |
|
||||
| Sync destructive | Avec confirmation (modal frontend) |
|
||||
| Custom fields — ajout | Créer `CustomFieldValue` vides |
|
||||
| Custom fields — suppression | Supprimer avec confirmation |
|
||||
| Custom fields — renommage | Propagation auto (label dans la définition) |
|
||||
| Custom fields — changement de type | Clear les valeurs avec confirmation |
|
||||
| Architecture backend | Strategy pattern (1 strategy par entity type) |
|
||||
| Déclenchement | En deux temps : preview séparé du sync |
|
||||
| Preview timing | AVANT le save (pas de rollback nécessaire) |
|
||||
| Pièces — produits liés | Nouvelle table `PieceProductSlot` remplace la M2M `piece_products` |
|
||||
| `restrictedMode` frontend | Supprimé complètement |
|
||||
| Versioning | `version` INT sur Composant, Pièce, Produit (incrémenté à chaque sync) |
|
||||
| Machines | Aucun changement — elles lisent les slots des composants, la sync met à jour ces slots |
|
||||
| Matching slots | Par `typeXxxId` + `position` (pas de FK vers skeleton requirement) |
|
||||
| Matching custom fields | Par `orderIndex` (propriété stable sur `CustomField`) |
|
||||
| Atomicité PATCH + sync | Wrappé dans une transaction DB côté controller |
|
||||
| Idempotence sync | `execute()` est idempotent — un double appel est un no-op |
|
||||
| Audit | Les opérations de sync sont capturées par les subscribers `onFlush` existants |
|
||||
|
||||
## Endpoints API
|
||||
|
||||
### `POST /api/model_types/{id}/sync-preview`
|
||||
|
||||
Calcule l'impact du diff entre le payload envoyé et l'état actuel des items liés. **Ne persiste rien.**
|
||||
|
||||
**Sécurité :** `ROLE_GESTIONNAIRE`
|
||||
|
||||
**Request body :**
|
||||
```json
|
||||
{
|
||||
"structure": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Le payload `structure` a le même format que celui envoyé au `PATCH /api/model_types/{id}`.
|
||||
|
||||
**Response :**
|
||||
```json
|
||||
{
|
||||
"modelTypeId": "cl...",
|
||||
"category": "COMPONENT",
|
||||
"itemCount": 12,
|
||||
"additions": {
|
||||
"pieceSlots": 12,
|
||||
"productSlots": 0,
|
||||
"subcomponentSlots": 24,
|
||||
"customFieldValues": 36
|
||||
},
|
||||
"deletions": {
|
||||
"pieceSlots": 0,
|
||||
"productSlots": 12,
|
||||
"subcomponentSlots": 0,
|
||||
"customFieldValues": 0
|
||||
},
|
||||
"modifications": {
|
||||
"customFieldTypeChanges": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Si `additions`, `deletions` et `modifications` sont tous à 0, le frontend skip la modal et sauvegarde directement.
|
||||
|
||||
**Erreurs :**
|
||||
- `404` — ModelType introuvable
|
||||
- `403` — droits insuffisants
|
||||
|
||||
### `POST /api/model_types/{id}/sync`
|
||||
|
||||
Exécute la propagation. Appelé **après** le `PATCH` du ModelType, dans la même requête frontend (PATCH + sync enchaînés).
|
||||
|
||||
**Sécurité :** `ROLE_GESTIONNAIRE`
|
||||
|
||||
**Request body :**
|
||||
```json
|
||||
{
|
||||
"confirmDeletions": true,
|
||||
"confirmTypeChanges": true
|
||||
}
|
||||
```
|
||||
|
||||
Si des suppressions sont nécessaires mais `confirmDeletions` est `false`, le sync **skip les suppressions** (applique uniquement les ajouts). Idem pour `confirmTypeChanges` et les clear de valeurs. Cela permet un sync partiel (ajouts only) sans confirmation.
|
||||
|
||||
**Response :** `200` avec résumé de l'exécution.
|
||||
|
||||
**Erreurs :**
|
||||
- `404` — ModelType introuvable
|
||||
- `403` — droits insuffisants
|
||||
|
||||
## Architecture Backend
|
||||
|
||||
### Strategy Pattern
|
||||
|
||||
```
|
||||
Service/
|
||||
├── ModelTypeSyncService.php # Orchestrateur
|
||||
└── Sync/
|
||||
├── SyncStrategyInterface.php # Interface
|
||||
├── ComposantSyncStrategy.php # Slots pièce/produit/sous-composant + custom fields
|
||||
├── PieceSyncStrategy.php # Slots produit + custom fields
|
||||
└── ProductSyncStrategy.php # Custom fields uniquement
|
||||
```
|
||||
|
||||
### Interface
|
||||
|
||||
```php
|
||||
interface SyncStrategyInterface
|
||||
{
|
||||
public function supports(ModelType $modelType): bool;
|
||||
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult;
|
||||
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult;
|
||||
}
|
||||
```
|
||||
|
||||
**Note sur `execute()` :** Cette méthode est appelée **après** le PATCH du ModelType, donc les skeleton requirements sont déjà mis à jour en base. La strategy compare les skeleton requirements actuels (fraîchement mis à jour) avec les slots existants des items liés. Pas besoin de recevoir `$newStructure` — les relations ORM reflètent déjà le nouvel état.
|
||||
|
||||
### Orchestrateur
|
||||
|
||||
```php
|
||||
class ModelTypeSyncService
|
||||
{
|
||||
/** @param iterable<SyncStrategyInterface> $strategies */
|
||||
public function __construct(private iterable $strategies) {}
|
||||
|
||||
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult
|
||||
{
|
||||
foreach ($this->strategies as $strategy) {
|
||||
if ($strategy->supports($modelType)) {
|
||||
return $strategy->preview($modelType, $newStructure);
|
||||
}
|
||||
}
|
||||
throw new \LogicException('No strategy found for category: ' . $modelType->getCategory()->value);
|
||||
}
|
||||
|
||||
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult
|
||||
{
|
||||
foreach ($this->strategies as $strategy) {
|
||||
if ($strategy->supports($modelType)) {
|
||||
return $strategy->execute($modelType, $confirmation);
|
||||
}
|
||||
}
|
||||
throw new \LogicException('No strategy found for category: ' . $modelType->getCategory()->value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Les strategies sont auto-injectées via `#[AutoconfigureTag('app.sync_strategy')]` et le tagged iterator de Symfony.
|
||||
|
||||
### DTOs
|
||||
|
||||
```php
|
||||
class SyncPreviewResult
|
||||
{
|
||||
public string $modelTypeId;
|
||||
public string $category;
|
||||
public int $itemCount;
|
||||
public array $additions; // ['pieceSlots' => int, 'productSlots' => int, ...]
|
||||
public array $deletions;
|
||||
public array $modifications;
|
||||
}
|
||||
|
||||
class SyncConfirmation
|
||||
{
|
||||
public bool $confirmDeletions = false;
|
||||
public bool $confirmTypeChanges = false;
|
||||
}
|
||||
|
||||
class SyncExecutionResult
|
||||
{
|
||||
public int $itemsUpdated;
|
||||
public array $additions;
|
||||
public array $deletions;
|
||||
public array $modifications;
|
||||
}
|
||||
```
|
||||
|
||||
### Controller
|
||||
|
||||
```php
|
||||
#[Route('/api/model_types/{id}')]
|
||||
class ModelTypeSyncController extends AbstractController
|
||||
{
|
||||
#[Route('/sync-preview', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_GESTIONNAIRE')]
|
||||
public function preview(ModelType $modelType, Request $request): JsonResponse
|
||||
{
|
||||
$structure = json_decode($request->getContent(), true)['structure'] ?? [];
|
||||
$result = $this->syncService->preview($modelType, $structure);
|
||||
return $this->json($result);
|
||||
}
|
||||
|
||||
#[Route('/sync', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_GESTIONNAIRE')]
|
||||
public function sync(ModelType $modelType, Request $request): JsonResponse
|
||||
{
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$confirmation = new SyncConfirmation();
|
||||
$confirmation->confirmDeletions = $body['confirmDeletions'] ?? false;
|
||||
$confirmation->confirmTypeChanges = $body['confirmTypeChanges'] ?? false;
|
||||
$result = $this->syncService->execute($modelType, $confirmation);
|
||||
return $this->json($result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Atomicité PATCH + Sync
|
||||
|
||||
Le frontend enchaîne `PATCH` puis `POST /sync` en deux requêtes HTTP. Le `POST /sync` wrappe toute l'opération dans une transaction DB (`$em->wrapInTransaction()`). Si le sync échoue, les modifications de slots sont rollback. Le PATCH du ModelType (skeleton requirements) reste committée — c'est acceptable car un re-sync est toujours possible.
|
||||
|
||||
En cas d'échec réseau entre le PATCH et le sync, le ModelType est à jour mais les items ne sont pas synchronisés. Le prochain save de la catégorie reproposera le sync-preview, qui détectera les différences.
|
||||
|
||||
## Logique de Diff / Sync
|
||||
|
||||
### Matching des slots
|
||||
|
||||
Pour chaque item lié, on compare ses slots actuels avec les skeleton requirements du ModelType. Le matching se fait par **`typeXxxId`** (le type référencé : `typePieceId`, `typeProductId`, `typeComposantId`) + **`position`**.
|
||||
|
||||
Il n'y a **pas de FK directe** entre un slot et un skeleton requirement. Le lien est implicite via le type + position.
|
||||
|
||||
**Pour le preview :** la strategy parse le `$newStructure` (payload JSON) et le compare aux slots actuels sans toucher à la DB.
|
||||
|
||||
**Pour l'execute :** la strategy lit les skeleton requirements actuels (déjà mis à jour par le PATCH) et les compare aux slots actuels.
|
||||
|
||||
### Règles — Slots (pièce, produit, sous-composant)
|
||||
|
||||
| Cas | Action |
|
||||
|-----|--------|
|
||||
| Skeleton requirement existe, pas de slot correspondant | **Ajouter** slot vide (type + position, `quantity = 1` pour pièces, pas de sélection) |
|
||||
| Slot existe, plus de skeleton requirement | **Supprimer** le slot (si `confirmDeletions`) — sélection perdue |
|
||||
| Les deux existent, position différente | **Mettre à jour** la position du slot |
|
||||
| Slot existe et matche | **Ne rien toucher** — sélection et quantité préservées |
|
||||
|
||||
### Règles — Custom fields
|
||||
|
||||
| Cas | Action |
|
||||
|-----|--------|
|
||||
| Nouveau custom field | **Créer** `CustomFieldValue` vides pour tous les items |
|
||||
| Custom field supprimé | **Supprimer** les `CustomFieldValue` (si `confirmDeletions`) |
|
||||
| Renommé (même `orderIndex`, nom différent) | **Propagation auto** — label dans la définition, valeurs intactes |
|
||||
| Type changé (même `orderIndex`, type différent) | **Clear** les valeurs (si `confirmTypeChanges`) — `CustomFieldValue` conservée, `value` vidée |
|
||||
|
||||
Le matching des custom fields se fait par **`orderIndex`** (propriété stable sur l'entité `CustomField`), pas par index de tableau. Cela évite les faux positifs lors de réordonnancement.
|
||||
|
||||
## Nouvelle Entité — `PieceProductSlot`
|
||||
|
||||
### Contexte — Remplacement de la M2M `piece_products`
|
||||
|
||||
Actuellement, les produits liés aux pièces passent par une relation M2M (`piece_products`, colonnes `a`/`b`). Cette table n'a pas de notion de `position`, `typeProductId`, ou `familyCode`.
|
||||
|
||||
`PieceProductSlot` **remplace** cette M2M pour uniformiser l'architecture avec les slots des Composants. La M2M existante sera conservée temporairement puis supprimée dans une migration future.
|
||||
|
||||
### Table `piece_product_slots`
|
||||
|
||||
| Colonne | Type | Contrainte |
|
||||
|---------|------|------------|
|
||||
| `id` | VARCHAR (CUID) | PK |
|
||||
| `pieceid` | VARCHAR | FK → `pieces.id` CASCADE |
|
||||
| `typeproductid` | VARCHAR | FK → `model_types.id` SET NULL, nullable |
|
||||
| `selectedproductid` | VARCHAR | FK → `products.id` SET NULL, nullable |
|
||||
| `familycode` | VARCHAR(255) | nullable |
|
||||
| `position` | INT | NOT NULL |
|
||||
| `createdat` | TIMESTAMP | NOT NULL |
|
||||
| `updatedat` | TIMESTAMP | NOT NULL |
|
||||
|
||||
### Entité PHP
|
||||
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'piece_product_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class PieceProductSlot
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: 'string')]
|
||||
private string $id;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'productSlots')]
|
||||
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Piece $piece;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeProduct = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Product::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Product $selectedProduct = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### Relation sur Piece
|
||||
|
||||
```php
|
||||
#[ORM\OneToMany(targetEntity: PieceProductSlot::class, mappedBy: 'piece', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $productSlots;
|
||||
```
|
||||
|
||||
La relation M2M `$products` existante sur `Piece` sera marquée deprecated puis supprimée dans une migration future.
|
||||
|
||||
### Migration
|
||||
|
||||
1. Créer la table `piece_product_slots`
|
||||
2. Migrer les données existantes de `piece_products` (M2M) → chaque entrée devient un `PieceProductSlot` avec `selectedProductId` renseigné, `typeProductId` déduit du produit sélectionné (`product.typeProduct`), `position` auto-incrémentée
|
||||
3. Conserver `piece_products` temporairement (suppression dans une migration future)
|
||||
4. Mettre à jour le virtual getter `getStructure()` de Piece pour lire les `productSlots`
|
||||
|
||||
## Versioning
|
||||
|
||||
### Nouveau champ sur Composant, Piece, Product
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: 'integer', options: ['default' => 1])]
|
||||
#[Groups(['composant:read'])] // idem pour piece:read, product:read
|
||||
private int $version = 1;
|
||||
```
|
||||
|
||||
### Comportement
|
||||
|
||||
- **Création** d'un item → `version = 1`
|
||||
- **Sync** qui modifie les slots ou custom fields d'un item → `version += 1`
|
||||
- Si la sync n'a aucun impact sur un item particulier (ses slots matchent déjà le skeleton), sa version ne change pas
|
||||
|
||||
### Migration
|
||||
|
||||
```sql
|
||||
ALTER TABLE composants ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1;
|
||||
ALTER TABLE pieces ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1;
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1;
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
### Suppression du `restrictedMode`
|
||||
|
||||
**Fichiers à supprimer :**
|
||||
- `composables/useCategoryEditGuard.ts`
|
||||
- `tests/composables/useCategoryEditGuard.test.ts`
|
||||
|
||||
**Fichiers à modifier (retirer restrictedMode) :**
|
||||
- `pages/component-category/[id]/edit.vue`
|
||||
- `pages/piece-category/[id]/edit.vue`
|
||||
- `pages/product-category/[id]/edit.vue`
|
||||
- `components/StructureNodeEditor.vue`
|
||||
- `components/PieceModelStructureEditor.vue`
|
||||
- `components/ComponentModelStructureEditor.vue`
|
||||
- `components/model-types/ModelTypeForm.vue`
|
||||
- `composables/useStructureNodeCrud.ts`
|
||||
- `composables/useStructureNodeLogic.ts`
|
||||
- `composables/usePieceStructureEditorLogic.ts`
|
||||
- `tests/components/ModelTypeForm.test.ts` (si existant)
|
||||
- `tests/components/PieceModelStructureEditor.test.ts`
|
||||
|
||||
### Nouveau composant — `SyncConfirmationModal.vue`
|
||||
|
||||
Modal DaisyUI qui reçoit un `SyncPreviewResult` et affiche :
|
||||
|
||||
```
|
||||
Cette modification impacte X [composants|pièces|produits] :
|
||||
|
||||
Ajouts :
|
||||
• Y slots pièce à créer
|
||||
• Z valeurs de champs personnalisés à initialiser
|
||||
|
||||
Suppressions :
|
||||
• W slots produit à supprimer (les sélections seront perdues)
|
||||
|
||||
Modifications :
|
||||
• V valeurs de champs à réinitialiser (changement de type)
|
||||
|
||||
[Annuler] [Confirmer la synchronisation]
|
||||
```
|
||||
|
||||
### Nouveau service — `modelTypes.ts`
|
||||
|
||||
Ajout de deux fonctions au service existant :
|
||||
|
||||
```typescript
|
||||
export function syncPreview(id: string, structure: any) {
|
||||
return requestFetch(`/api/model_types/${id}/sync-preview`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ structure }),
|
||||
})
|
||||
}
|
||||
|
||||
export function syncExecute(id: string, confirmation: { confirmDeletions: boolean, confirmTypeChanges: boolean }) {
|
||||
return requestFetch(`/api/model_types/${id}/sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(confirmation),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Flow dans les pages d'édition de catégorie
|
||||
|
||||
```typescript
|
||||
const handleSubmit = async (payload) => {
|
||||
// 1. Preview (avant le save)
|
||||
const preview = await syncPreview(id, payload.structure)
|
||||
|
||||
const hasImpact = preview.itemCount > 0 && (
|
||||
Object.values(preview.additions).some(v => v > 0) ||
|
||||
Object.values(preview.deletions).some(v => v > 0) ||
|
||||
Object.values(preview.modifications).some(v => v > 0)
|
||||
)
|
||||
|
||||
// 2. Si impact, demander confirmation
|
||||
if (hasImpact) {
|
||||
pendingPayload.value = payload
|
||||
syncPreviewData.value = preview
|
||||
showSyncModal.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Pas d'impact → save direct (PATCH + sync)
|
||||
await saveAndSync(payload, { confirmDeletions: false, confirmTypeChanges: false })
|
||||
}
|
||||
|
||||
const onSyncConfirmed = async () => {
|
||||
const preview = syncPreviewData.value
|
||||
const needsDeleteConfirm = Object.values(preview.deletions).some(v => v > 0)
|
||||
const needsTypeChangeConfirm = preview.modifications.customFieldTypeChanges > 0
|
||||
|
||||
await saveAndSync(pendingPayload.value, {
|
||||
confirmDeletions: needsDeleteConfirm,
|
||||
confirmTypeChanges: needsTypeChangeConfirm,
|
||||
})
|
||||
}
|
||||
|
||||
const saveAndSync = async (payload, confirmation) => {
|
||||
await updateModelType(id, payload)
|
||||
await syncExecute(id, confirmation)
|
||||
showSuccess('Catégorie mise à jour et synchronisée.')
|
||||
router.push('/...')
|
||||
}
|
||||
```
|
||||
|
||||
## Non-régression
|
||||
|
||||
### Machines
|
||||
|
||||
- Le `MachineStructureController` lit les slots des composants. La sync modifie ces slots → les machines affichent automatiquement la dernière version au prochain chargement.
|
||||
- Aucun changement dans le controller machine.
|
||||
|
||||
### Quantités
|
||||
|
||||
- La sync **ne touche jamais** aux slots qui matchent toujours un skeleton requirement. Les quantités (`ComposantPieceSlot.quantity`) et sélections existantes sont préservées.
|
||||
- Les nouveaux slots ajoutés par la sync ont `quantity = 1` par défaut.
|
||||
- Le `ComposantPieceSlotController` (PATCH quantity) reste inchangé.
|
||||
|
||||
### `PieceProductSlot` — pas de quantité
|
||||
|
||||
Cohérent avec `ComposantProductSlot` qui n'a pas de quantité non plus.
|
||||
|
||||
### Relation M2M `piece_products`
|
||||
|
||||
La M2M existante reste en base pendant la période de transition. Le code frontend/backend qui la lit devra être migré vers les `productSlots`. La M2M sera supprimée dans une migration future une fois que tout le code utilise les slots.
|
||||
|
||||
## Tests
|
||||
|
||||
### Backend — PHPUnit
|
||||
|
||||
- `ModelTypeSyncControllerTest` — tests des endpoints preview et sync (y compris erreurs 403/404, confirmations partielles)
|
||||
- `ComposantSyncStrategyTest` — logique de diff pour composants (ajout, suppression, position update, no-op)
|
||||
- `PieceSyncStrategyTest` — logique de diff pour pièces (ajout/suppression de product slots)
|
||||
- `ProductSyncStrategyTest` — logique de diff pour produits (custom fields only)
|
||||
- `PieceProductSlotTest` — CRUD de la nouvelle entité
|
||||
- Idempotence : vérifier qu'un double appel à `sync` est un no-op
|
||||
- Vérifier la non-régression : `MachineStructureControllerTest` existant doit passer sans modification
|
||||
|
||||
### Frontend — Tests
|
||||
|
||||
- Supprimer `tests/composables/useCategoryEditGuard.test.ts`
|
||||
- Mettre à jour `tests/components/PieceModelStructureEditor.test.ts` (retirer restrictedMode)
|
||||
- Ajouter tests pour le flow sync dans les pages d'édition (preview → modal → confirm → save)
|
||||
|
||||
## Performance
|
||||
|
||||
Pour le volume attendu (dizaines d'items par catégorie, pas de milliers), la sync en PHP avec l'ORM Doctrine est suffisante. Si le volume augmente significativement, les opérations de création/suppression de slots pourront être converties en batch SQL (INSERT ... SELECT, DELETE ... WHERE) sans changer l'architecture (la strategy encapsule la logique).
|
||||
@@ -105,6 +105,114 @@ INSERT INTO public.model_types (id, name, code, category, notes, createdat, upda
|
||||
INSERT INTO public.model_types (id, name, code, category, notes, createdat, updatedat, description, componentskeleton, pieceskeleton, productskeleton) VALUES ('cle48e33ef67853069badfc5f0', 'testcor', 'testcor', 'COMPONENT', 'nnd', '2026-01-25 11:27:47', '2026-01-25 11:27:47', 'nnd', '{"pieces": [{"typePieceId": "cmgs1sco0002k47056yq8eyfq", "typePieceLabel": "Bavette"}], "products": [{"familyCode": "lubrifiant", "typeProductId": "cmhn9zrm5000247s8ds3bmpaf"}], "customFields": [{"key": "uu", "value": {"type": "text", "required": false}}], "subcomponents": [{"alias": "Trémie d''alimentation", "familyCode": "Tremie", "typeComposantId": "cmgs0htn5002d4705tyxxiqb2"}]}', NULL, NULL);
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: skeleton_piece_requirements; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0939a6d5cf1e2f42ea338ed9', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrnu6zc000f470565mc8hha', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clc8a5a3ffde2e012115f4a4a6', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrnxlx5000g47059oyj4yuw', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl19977b37375e768874a9bc21', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrohigo000z4705q8yvpih0', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('claa3bd610232237f37b5305f5', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrou6670011470586ipgylm', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl8cc6f7b1c0d2356200710cff', 'cmgrp0u0h00124705xxyt5fqu', 'cmgroij2f00104705t6y33enk', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl6ab668cf6c44971a6e8c15c1', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrnu6zc000f470565mc8hha', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl9d6c1d30a8f1f37d6f5919ca', 'cmgrzrlcc001t47054emo6cfb', 'cmgrzuwkj001u47057u8hej9u', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl89cc18036bb3a12d4d0cd427', 'cmgrzrlcc001t47054emo6cfb', 'cmgrzuwkj001u47057u8hej9u', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl6f6e8f47c6a731bcd9288bba', 'cmgrzrlcc001t47054emo6cfb', 'cmgrzuwkj001u47057u8hej9u', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clc7fe6f18be36b9cd60089a28', 'cmgrzrlcc001t47054emo6cfb', 'cmgroij2f00104705t6y33enk', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl15ff333888d9f160a80a04e5', 'cmgrzrlcc001t47054emo6cfb', 'cmgroij2f00104705t6y33enk', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl82c521ac9e0cae5ba31d2880', 'cmgs0h5ze002c4705szh85svi', 'cmgroij2f00104705t6y33enk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl3eef5c0837bdf0aed9da51b3', 'cmgs0h5ze002c4705szh85svi', 'cmgs0kd5o002f47053b7n8tw6', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl2533d3d3f215df0c180b2c4a', 'cmgs0h5ze002c4705szh85svi', 'cmgrzuwkj001u47057u8hej9u', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl3ff44ebbbd85d8750328183c', 'cmgs0h5ze002c4705szh85svi', 'cmgroij2f00104705t6y33enk', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clbc503ec073758ef2f4cd2a4f', 'cmgs0htn5002d4705tyxxiqb2', 'cmgs13jjp002h4705rjqzz5lh', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl80631b5e0a1ab10d4af65f6f', 'cmgs0htn5002d4705tyxxiqb2', 'cmgs1s4pv002j470567o60oqe', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clc11a5ad82b02a76eca599117', 'cmgs0htn5002d4705tyxxiqb2', 'cmgs1sco0002k47056yq8eyfq', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clbd335edf794794fba25546a6', 'cmgs0htn5002d4705tyxxiqb2', 'cmgs1sco0002k47056yq8eyfq', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl218404db04ffcd7c8e200f0a', 'cmgs0i4je002e4705ndrhe26e', 'cmgroij2f00104705t6y33enk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl31265ec0dcf2fc4e252c2181', 'cmgs0i4je002e4705ndrhe26e', 'cmgrzuwkj001u47057u8hej9u', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl107b717d92362218aa387539', 'cmgs0i4je002e4705ndrhe26e', 'cmgujpyjf002q4705j6hv1nkk', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5ee1bef2ef4b88f83a680526', 'cmgs0i4je002e4705ndrhe26e', 'cmgs1s4pv002j470567o60oqe', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl49eb01162df881cc87bc281f', 'cmgs0i4je002e4705ndrhe26e', 'cmgrnxlx5000g47059oyj4yuw', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl1369592934d41016018dbbbf', 'cmgs0i4je002e4705ndrhe26e', 'cmgujpyjf002q4705j6hv1nkk', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cld7bf0d8c46d0037cbb16cd69', 'cmgs0i4je002e4705ndrhe26e', 'cmgs1s4pv002j470567o60oqe', 6, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl75660a7f65559f164df06538', 'cmgs0i4je002e4705ndrhe26e', 'cm_motoreducteur_frein_01', 7, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl859ade5b78b6e833d1eb64f8', 'cmgs0i4je002e4705ndrhe26e', 'cm_motoreducteur_frein_01', 8, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cld23bbbeff05e238c6d189da0', 'cmgs0i4je002e4705ndrhe26e', 'cmgukvztv002s4705kqvqjtvg', 9, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5e7a3eeeb8a6283ce828edf4', 'cmgs0i4je002e4705ndrhe26e', 'cmgukvztv002s4705kqvqjtvg', 10, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl52149961cdaed3f614dd87c1', 'cmgs0i4je002e4705ndrhe26e', 'cmgukxw26002t4705qz4ul929', 11, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cle1d646b02f65c7a5fd276ecf', 'cmgs0i4je002e4705ndrhe26e', 'cmgum1ih0000347ff7bsldmnv', 12, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl07b21cfdc0f2e9d8c11c79ed', 'cmgs0i4je002e4705ndrhe26e', 'cmgulzr7b000247ffpr2vsput', 13, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clb726dc1c94d371d8860ef92d', 'cmgs0i4je002e4705ndrhe26e', 'cmgum1wsl000447ffa109dtag', 14, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cle5605528c6fbb3de1f72b8d8', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clbd602414e77f0309cfeb92c6', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl604a3fa207118914f58f5ffb', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl016fd78557d8ac5ce883208b', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl29eeddd05188eec4e782aca3', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl7e7a30ba2126ecfed7f7501c', 'cmgujizjf002o4705kfdea5yw', 'cmgz0qu29004a47ffw1bmjr75', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clb893d014b683a1851425436e', 'cmgujizjf002o4705kfdea5yw', 'cmgz0qu29004a47ffw1bmjr75', 6, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0b0094ca0a92321f5fa2131c', 'cmgujizjf002o4705kfdea5yw', 'cmgz0v9k4004v47ff8apimo50', 7, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clea857995e00ebb10548095c2', 'cmgujizjf002o4705kfdea5yw', 'cmgz0v9k4004v47ff8apimo50', 8, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl1341b6e156f059e852f719e2', 'cmgujizjf002o4705kfdea5yw', 'cmgz0v9k4004v47ff8apimo50', 9, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cle18462b08b605f31fec6cc14', 'cmgujizjf002o4705kfdea5yw', 'cmgz0v9k4004v47ff8apimo50', 10, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clf3f7ad1fcb6eb3937b96bb8e', 'cmgujizjf002o4705kfdea5yw', 'cmgz0zs4m006447ffq5b20ch3', 11, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clfdd9dfcc06816afd8bf8503a', 'cmgujizjf002o4705kfdea5yw', 'cmgz0zs4m006447ffq5b20ch3', 12, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl4af9588815f4a5a21cf4f979', 'cmgujizjf002o4705kfdea5yw', 'cmgz0zs4m006447ffq5b20ch3', 13, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0e0d7053e7a0518b50f00481', 'cmgujizjf002o4705kfdea5yw', 'cmgz0zs4m006447ffq5b20ch3', 14, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl4f64c44ed0ffc92a923edccc', 'cmgujizjf002o4705kfdea5yw', 'cmgz17bpz006t47ff58i3j1e1', 15, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clfbdc4656e36b47a8e6a5df68', 'cmgujizjf002o4705kfdea5yw', 'cmgz17bpz006t47ff58i3j1e1', 16, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl4860204e0478d9f4eb806b85', 'cmgujjmpo002p470523lbfqmp', 'cmgum1wsl000447ffa109dtag', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5e3367975bfef877130e41af', 'cmgujjmpo002p470523lbfqmp', 'cmgum1wsl000447ffa109dtag', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0c004dd01a3f4a978c8260c9', 'cmh0kmyh1000847s5ciu9agzo', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl96109b5962d80d95d279fd96', 'cmh0kmyh1000847s5ciu9agzo', 'cmgytewe0002447ffup09bscr', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5a6e124fa8ec83ae5ef21685', 'cmh20wuye001o47s5auvmq7s8', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clba2c60a3a4aab6a82eccad61', 'cmh20x49u001q47s5l9ahnvms', 'cmgs1sco0002k47056yq8eyfq', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clbf346fb41ad28af45eb5e7aa', 'cmh20x49u001q47s5l9ahnvms', 'cmgs1sco0002k47056yq8eyfq', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl50ec69f08be67181d3cd5dc3', 'cmh20yrgb001v47s54uxvi6km', 'cmgs1sco0002k47056yq8eyfq', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl84cab14314d4932ef52e9d47', 'cmh20yrgb001v47s54uxvi6km', 'cmgujpyjf002q4705j6hv1nkk', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cle258e4bb4ae12d90aeb49ae7', 'cmh20yrgb001v47s54uxvi6km', 'cmgujpyjf002q4705j6hv1nkk', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl697c36fe5e29f3cfdb209537', 'cmh20yrgb001v47s54uxvi6km', 'cmgujpyjf002q4705j6hv1nkk', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl112cc747d5ac2c68716bd6d4', 'cmh23pwbo002947s5ide6zx7g', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl62fe319d24840a318d24b682', 'cmkqpdc7a001o1eq6iwqvi3jk', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0051b0a075490c406f21213e', 'cmkqq45yq00251eq6k1z0x7kt', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clc578afcad0f42694f05408f0', 'cmkqqdogo002l1eq6vy26j33g', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0a3a926192f808233c894c43', 'cmkqqdogo002l1eq6vy26j33g', 'cmh9bykt8001j47v7g0oej5dw', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl8a927a5de52db535ba8f062a', 'cmkqqdogo002l1eq6vy26j33g', 'cmhabzypq003h47v7jyjjxst1', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl8f60c43f25ec1c96560209b0', 'cmkqqdogo002l1eq6vy26j33g', 'cmgz0zs4m006447ffq5b20ch3', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5c71200fd7efe31c9764e5d8', 'cmkqqdogo002l1eq6vy26j33g', 'cmkdqtcpv001r1e2wptehmkxi', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clf6147721769314755e9ff6b8', 'cmkqqdogo002l1eq6vy26j33g', 'cmhbve5h30016475utwgpa32k', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: skeleton_product_requirements; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.skeleton_product_requirements (id, modeltypeid, typeproductid, familycode, position, createdat, updatedat) VALUES ('cle44c43c22390db28f97b1c17', 'cmkqqdogo002l1eq6vy26j33g', 'cmhn9ze17000147s81dlr4i3v', 'graisse', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: skeleton_subcomponent_requirements; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl9806dc8b4b42ea0c84b6832b', 'cmgs0i4je002e4705ndrhe26e', NULL, 'Kit', 'kit', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl67fc8aef811eb876a1cde9ed', 'cmgs0i4je002e4705ndrhe26e', NULL, 'Kit', 'kit', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl6cff73d32c4f16b5d4e909ab', 'cmgujjmpo002p470523lbfqmp', NULL, 'Kit', 'kit', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl7f83953a8f78f951f3a3628c', 'cmgz1az8d007g47fflwxk3q95', NULL, 'Contrôleur de rotation', 'controleur-de-rotation', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cle79fbc1bedd4123874a35377', 'cmgz1az8d007g47fflwxk3q95', NULL, 'Kit', 'kit', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl23c07d5454e5f2bc6d7703fa', 'cmgz1az8d007g47fflwxk3q95', NULL, 'Kit', 'kit', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cla3530a303f28a12d97883d82', 'cmgz1az8d007g47fflwxk3q95', NULL, 'Contrôleur de rotation', 'controleur-de-rotation', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('clefbd652fc2561a180f38dd89', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Tête convoyeur à bande', 'tcb', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl70978104735f0e8addcbb494', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Pied convoyeur à bande', 'PCB', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl63db65c869d07fd36d0d3422', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Elément intermédiaire & coude', 'EIC', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('clac7e30f33f20287f76197c9e', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Trémie d''alimentation', 'Tremie', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl081ba5565b6e9320be0c88b0', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Chariot Déverseur', 'Chariot', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl164ed242e2148aac3b30c391', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Commande moteur', 'commande-moteur', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl7aa47c4c4e4070ad1925ee27', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Déport de bande', 'deport-de-bande-et-controleur-de-rotation', 6, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl7c497e23a2deb7e85a2c2abe', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Contrôleur de rotation', 'controleur-de-rotation', 7, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl4a4fc72af5a46c4d369f535e', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Contrôleur de rotation', 'controleur-de-rotation', 8, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('clce09e33e0f970116c87057c7', 'cmh0kmyh1000847s5ciu9agzo', 'cmh20z6g4001x47s5b5hturac', 'Paliers', 'paliers', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('clb5e1e758aaed9688a4e7db60', 'cmh0kmyh1000847s5ciu9agzo', 'cmh4x8m4k000047nko0vwavbg', 'Roulement', 'roulement', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: products; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
@@ -178,6 +286,111 @@ INSERT INTO public.composants (id, name, reference, prix, createdat, updatedat,
|
||||
INSERT INTO public.composants (id, name, reference, prix, createdat, updatedat, typecomposantid, structure, productid) VALUES ('cl9b1583768c7c9fe6cfe93a11', 'testcor', 'll', 3.00, '2026-01-25 11:28:27', '2026-01-25 11:28:27', 'cle48e33ef67853069badfc5f0', '{"path":"root","definition":[],"pieces":[{"path":"root:piece-0","definition":{"typePieceId":"cmgs1sco0002k47056yq8eyfq","typePieceLabel":"Bavette"},"selectedPieceId":"cmgs1tfza002m4705mbl0kwok"}],"products":[{"path":"root:product-0","definition":{"typeProductId":"cmhn9zrm5000247s8ds3bmpaf","familyCode":"lubrifiant"},"selectedProductId":"cmkp97us6007k1e2ws0ogux1m"}],"subcomponents":[{"path":"root:sub-0","definition":{"alias":"Tr\u00e9mie d''alimentation","typeComposantId":"cmgs0htn5002d4705tyxxiqb2","familyCode":"Tremie"},"selectedComponentId":"cmgz5h2s0009v47ff6x26cqry"}]}', 'cmkp97us6007k1e2ws0ogux1m');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: composant_piece_slots; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clcb580f2bc13c2f6ffefdfe47', 'cmgz53uvt009s47ff9v0uklr6', 'cmgrnu6zc000f470565mc8hha', 'cmgrnzbku000h4705qrj5eujb', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cleac90912bbe1571ec196c3c9', 'cmgz53uvt009s47ff9v0uklr6', 'cmgrohigo000z4705q8yvpih0', 'cmgrp2sju00144705n8etw7im', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl7b2de7e0f607306317eb6e69', 'cmgz53uvt009s47ff9v0uklr6', 'cmgrou6670011470586ipgylm', 'cmgrp1ry9001347052qn8q2yo', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl8a1a4da80556a964e7267773', 'cmgz53uvt009s47ff9v0uklr6', 'cmgroij2f00104705t6y33enk', 'cmgrp3lhv00194705f1xp8j0m', 1, 4, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl51a23d93f8c8fbf0555d8d4e', 'cmgz53uvt009s47ff9v0uklr6', 'cmgrnu6zc000f470565mc8hha', 'cmgz516t7009n47fft3nfyt34', 1, 5, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clb7caab438a04ccdbe72c7662', 'cmgz5ef4h009t47ffmxveesp0', 'cmgrzuwkj001u47057u8hej9u', 'cmgs07df2001w4705ry79yvbo', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl4e710917a9cc405aea45eae4', 'cmgz5ef4h009t47ffmxveesp0', 'cmgrzuwkj001u47057u8hej9u', 'cmgrzvdmo001v47050tvf2z88', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl0b03faf21c00093950c06434', 'cmgz5ef4h009t47ffmxveesp0', 'cmgrzuwkj001u47057u8hej9u', 'cmgs08kjb00234705wc5tytxg', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl2151fb8b1d31e27aeea1ba63', 'cmgz5ef4h009t47ffmxveesp0', 'cmgroij2f00104705t6y33enk', 'cmgrp3lhv00194705f1xp8j0m', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl1fb766a77cd4c06b0dd6f68c', 'cmgz5ef4h009t47ffmxveesp0', 'cmgroij2f00104705t6y33enk', 'cmgs0bive00274705zjmiuwzo', 1, 4, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clbb1e23854540497ce8a0cc04', 'cmgz5fsvz009u47ffkrardb1u', 'cmgroij2f00104705t6y33enk', 'cmgrp3lhv00194705f1xp8j0m', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl3410d53157d1426a7c0fee70', 'cmgz5fsvz009u47ffkrardb1u', 'cmgs0kd5o002f47053b7n8tw6', 'cmgs0nyk7002g4705rteyvw7x', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl6a3fbc2af5b82193cb7eb86c', 'cmgz5fsvz009u47ffkrardb1u', 'cmgrzuwkj001u47057u8hej9u', 'cmgrzvdmo001v47050tvf2z88', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle60fa4fef139b2e86fc5bf47', 'cmgz5fsvz009u47ffkrardb1u', 'cmgroij2f00104705t6y33enk', 'cmgs0bive00274705zjmiuwzo', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('claf42f4acd9a024263ffb60c0', 'cmgz5h2s0009v47ff6x26cqry', 'cmgs13jjp002h4705rjqzz5lh', 'cmgs14c7a002i4705t1w4qdfx', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl44cc1edd9d00de1a675810d6', 'cmgz5h2s0009v47ff6x26cqry', 'cmgs1s4pv002j470567o60oqe', 'cmgs1swl0002l4705gpyg1yyn', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl0b8eb4a8defab5cde3bccd3d', 'cmgz5h2s0009v47ff6x26cqry', 'cmgs1sco0002k47056yq8eyfq', 'cmgs1tfza002m4705mbl0kwok', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl86e3c1391165cd65555856c1', 'cmgz5h2s0009v47ff6x26cqry', 'cmgs1sco0002k47056yq8eyfq', 'cmgs1tvrs002n4705gpym7vel', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clbc122534f74362e26882cfd1', 'cmgz79ivv009x47ffeh6of72i', 'cmgroij2f00104705t6y33enk', 'cmgrp3lhv00194705f1xp8j0m', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cla3b28a687250b61e1344dcc3', 'cmgz79ivv009x47ffeh6of72i', 'cmgrzuwkj001u47057u8hej9u', 'cmgum5zm0000547ffzg8ofiqr', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl8e42ccab069fa0ecd80a2cde', 'cmgz79ivv009x47ffeh6of72i', 'cmgujpyjf002q4705j6hv1nkk', 'cmgum9bn4000847fffbazanc5', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl786b5dc9712d2488ee7d1258', 'cmgz79ivv009x47ffeh6of72i', 'cmgs1s4pv002j470567o60oqe', 'cmgyruhgm000947ffmhhrqdrl', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl168e7708c9d3c91d5a404a5e', 'cmgz79ivv009x47ffeh6of72i', 'cmgujpyjf002q4705j6hv1nkk', 'cmgyrzrbc000h47ffd670wu8j', 1, 5, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl1fd495eaa3cec6667cfab566', 'cmgz79ivv009x47ffeh6of72i', 'cmgs1s4pv002j470567o60oqe', 'cmgys0mgx000i47ffxbftvqt4', 1, 6, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clbf85161d0d8ffef1d74220b6', 'cmgz79ivv009x47ffeh6of72i', 'cm_motoreducteur_frein_01', 'cmgys1osr000j47fftblpdpu2', 1, 7, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cld72b4dce93cb64a7909f3db6', 'cmgz79ivv009x47ffeh6of72i', 'cm_motoreducteur_frein_01', 'cmgys3ugw001147ffq33udxaw', 1, 8, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle12f0173310ab9c752f8fb00', 'cmgz79ivv009x47ffeh6of72i', 'cmgukvztv002s4705kqvqjtvg', 'cmgys4a2s001847ffhqhz7zcd', 1, 9, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl5782cb522ada0d1de03c4d86', 'cmgz79ivv009x47ffeh6of72i', 'cmgukvztv002s4705kqvqjtvg', 'cmgys6k6b001h47ffuq44ze37', 1, 10, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl5d8c4c8e361ccdbaa041e5b9', 'cmgz79ivv009x47ffeh6of72i', 'cmgukxw26002t4705qz4ul929', 'cmgys7anf001m47ff0ulcp092', 1, 11, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clb1c4e1e8f6a00f1b11f24884', 'cmgz79ivv009x47ffeh6of72i', 'cmgum1ih0000347ff7bsldmnv', 'cmgys8mjl001r47ff5f8z85fs', 1, 12, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl4f4f675958d1667659498076', 'cmgz79ivv009x47ffeh6of72i', 'cmgulzr7b000247ffpr2vsput', 'cmgysatbl001u47ffu55db8gg', 1, 13, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cla3442d7af57410a430dd3c7e', 'cmgz79ivv009x47ffeh6of72i', 'cmgum1wsl000447ffa109dtag', 'cmgysj6wn002347ffgq3f98dr', 1, 14, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl0c706745220aa12658edc03c', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgytmhw0002547ffzobsmpaa', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clca63f8c3aae40ebd68cc55b7', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgytqtc8002u47ffum90ylo5', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle09fbef03d0edcd66b0a4a0c', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgyts8s9003747ffd1h8husf', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl57b44902fd7409abccdab4ae', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgytuf06003k47ffdr8lvp13', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl47ee8065661468bc113d200a', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgytx2ul003x47ffhdpurtx5', 1, 4, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl6cbbd1f5046e9dfc1a89a358', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0v9k4004v47ff8apimo50', 'cmgz0w66p004w47ffvj6xcxmo', 1, 7, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle4cb39506f39efaf51af3e9e', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0v9k4004v47ff8apimo50', 'cmgz0xbx5005d47ffjkrafetg', 1, 8, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl8e47336d9326ac471ae821e7', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0v9k4004v47ff8apimo50', 'cmgz0y2aw005m47ff4zkjczei', 1, 9, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl5ae18ec62736f96d273353ec', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0v9k4004v47ff8apimo50', 'cmgz0yt33005v47ffy4p8d28z', 1, 10, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl524831b7cfa901ddc72e1728', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0zs4m006447ffq5b20ch3', 'cmgz10g67006547ffj28sqequ', 1, 11, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl4198321b8039385517c118ba', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0zs4m006447ffq5b20ch3', 'cmgz112hd006e47ffvg37mkoq', 1, 12, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl9fab43583266bb8abfe94d4d', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0zs4m006447ffq5b20ch3', 'cmgz11p1k006j47ffhjqgrnkp', 1, 13, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cled20136adaf599c72facf1a5', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0zs4m006447ffq5b20ch3', 'cmgz128lz006o47ffwfgtag7e', 1, 14, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clca666296d8db92cabc1299d6', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz17bpz006t47ff58i3j1e1', 'cmgz17w9w006u47ffg6db710j', 1, 15, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl565591562f915536f015cf0a', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz17bpz006t47ff58i3j1e1', 'cmgz18vw1007947ffr2wg86sa', 1, 16, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl8a5cc24bf84edeaf24b87405', 'cmgz7igun009z47ffhea93fbw', 'cmgum1wsl000447ffa109dtag', 'cmgz1c9wx007h47ffr41untmr', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle6cc6a2990d51326b7a16ac2', 'cmgz7igun009z47ffhea93fbw', 'cmgum1wsl000447ffa109dtag', 'cmgz1erci007r47ffybtdepul', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clf8105cc1fa7b62c4616cf509', 'cmh314rnj002q47s5cr3n6445', 'cmgujpyjf002q4705j6hv1nkk', 'cmh313676002d47s5li4e6qt9', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cla7987d4fe2fb2bf8ebe8ce48', 'cmh3jnikd002147zbmmnx2qw8', 'cmgujpyjf002q4705j6hv1nkk', 'cmh31ejnw003b47s548rtk8b1', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clb39042e520687e6c6b51f788', 'cl10c0924d10135c5f515378ac', 'cmgujpyjf002q4705j6hv1nkk', 'cl7b3702f04d24d87e47232a14', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl50fa6580ea24bfbcfb018cac', 'cl10eedbb54a0d2cd0fa3ce9c6', 'cmgujpyjf002q4705j6hv1nkk', 'cle1db7051dbef91fc009073a6', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl2289dce68b996d2c0b56cd49', 'cl36d84884cad86fbc92dba133', 'cmgujpyjf002q4705j6hv1nkk', 'clbf9f0070ebd464b3c309c646', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cla130de58dd731b934e1f9789', 'cl3dbac5194bc192a0589465ba', 'cmgujpyjf002q4705j6hv1nkk', 'cl50fe870a07e42759b37b511f', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl75bfb7413b5df226846ab367', 'cl4660bae41d2af254e6c3b726', 'cmgujpyjf002q4705j6hv1nkk', 'cl4e975566464253882018adcc', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl076c31311e25600aae5d07e2', 'cl54b1b4509971fde475572b29', 'cmgujpyjf002q4705j6hv1nkk', 'cl731386df55fcb9e6a01e0a63', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl4874e08b827307a1313a71b5', 'cl5a8f9656aa7e14c012f30700', 'cmgujpyjf002q4705j6hv1nkk', 'cl5ee293dc7b61feba510082a4', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl6935b95a12cd846b6a3bcbfc', 'cl5b5e336095de8d4ece81b2dc', 'cmgujpyjf002q4705j6hv1nkk', 'cldd656c6092225f53a22badc0', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clf30ffdeac20200df2d190c45', 'cl5e9c6b18bccd38517026dc1c', 'cmgujpyjf002q4705j6hv1nkk', 'clfa3147270e5e66f9b52c425e', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clb8fa63511f6514497e8c1a5f', 'cl7df36c9e7391df3d4ff46102', 'cmgujpyjf002q4705j6hv1nkk', 'cl1406ef19de58fdd1adf40221', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl2251745f51b584dd8bd73aad', 'cl7f254c23161d9c853c3e6d92', 'cmgujpyjf002q4705j6hv1nkk', 'clf16e543545eddd01b20077df', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle99713d099f9a006931a7863', 'cl8b9b36f5a822aae21edb5a5f', 'cmgujpyjf002q4705j6hv1nkk', 'cl6667d159f6d07ba77fa79b39', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl469909161ea4061d1b514f06', 'cla833681664bb851ca61aca51', 'cmgujpyjf002q4705j6hv1nkk', 'cl8570d729efd017c12a2d5c3d', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clf9f5aaccf25ef3a54ace17ad', 'clba5633e840726188261145f9', 'cmgujpyjf002q4705j6hv1nkk', 'clafaa71cbf49777fbb8415f19', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cld23a4f8bfc3894842b531b47', 'clbd1e945fb222e1c56dd43941', 'cmgujpyjf002q4705j6hv1nkk', 'clc08fbdcd334ed869772d98ee', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl88ea31be5c6114d3267daaad', 'clbe710810fd7ccd09811957b3', 'cmgujpyjf002q4705j6hv1nkk', 'cmh313676002d47s5li4e6qt9', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cldb0304d3089db7aced359a4f', 'cldd7f161d2cd08ee54e79161e', 'cmgujpyjf002q4705j6hv1nkk', 'cl22c13dbc4d38a1f846323ae6', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl6e857d06dd103028af452bf6', 'cle98225ad3a32f5d8531950ef', 'cmgujpyjf002q4705j6hv1nkk', 'cl531dde45c3fc64c1a3b16ca0', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: composant_subcomponent_slots; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl114d5a6febff03d69286b6b7', 'cmgz79ivv009x47ffeh6of72i', 'Kit', 'kit', NULL, 'cmgz49bm2009547ff7ham94wz', 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl1d648f7c5a3b0ee64b22e69b', 'cmgz79ivv009x47ffeh6of72i', 'Kit', 'kit', NULL, 'cmgz4bczt009647ffzidmc8tc', 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl710c40366df98d7aeb547457', 'cmgz7igun009z47ffhea93fbw', 'Kit', 'kit', NULL, 'cmgz4equ5009747ffq665rpeb', 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl23ac52af05e90d9c7d2daba2', 'cmh0d59v5000347s561ahbept', 'Tête convoyeur à bande', 'tcb', NULL, 'cmgz53uvt009s47ff9v0uklr6', 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl5c1f5ff5fb762ffb1c62d02c', 'cmh0d59v5000347s561ahbept', 'Pied convoyeur à bande', 'PCB', NULL, 'cmgz5ef4h009t47ffmxveesp0', 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl9a90d9876471267669c5d83b', 'cmh0d59v5000347s561ahbept', 'Elément intermédiaire & coude', 'EIC', NULL, 'cmgz5fsvz009u47ffkrardb1u', 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('claeaa69eb72eff09abe7ef3fb', 'cmh0d59v5000347s561ahbept', 'Trémie d''alimentation', 'Tremie', NULL, 'cmgz5h2s0009v47ff6x26cqry', 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('clfcb74e5cc092b753c65f74f8', 'cmh0d59v5000347s561ahbept', 'Chariot Déverseur', 'Chariot', NULL, 'cmgz79ivv009x47ffeh6of72i', 4, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl8bb434545f2bca42d73d588e', 'cmh0d59v5000347s561ahbept', 'Commande moteur', 'commande-moteur', NULL, 'cmgz7fd3l009y47fff1l4g0p0', 5, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cle71559358e6346f59657d3d5', 'cmh0d59v5000347s561ahbept', 'Déport de bande', 'deport-de-bande-et-controleur-de-rotation', NULL, 'cmgz7igun009z47ffhea93fbw', 6, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl4a7d21f525c63c0581af5fc6', 'cmh0d59v5000347s561ahbept', 'Contrôleur de rotation', 'controleur-de-rotation', NULL, 'cmgz4qzap009b47ffj7ch6th7', 7, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl9f39d9a471c6d1d9ae4c4654', 'cmh0d59v5000347s561ahbept', 'Contrôleur de rotation', 'controleur-de-rotation', NULL, 'cmgz4r99x009c47ffco9f2img', 8, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: composant_product_slots; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: piece_products; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: constructeurs; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
@@ -257,121 +470,121 @@ INSERT INTO public.machines (id, name, reference, prix, createdat, updatedat, si
|
||||
-- Data for Name: pieces; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrp1ry9001347052qn8q2yo', 'Lame raclette', 'P40S069915', NULL, '2025-10-15 07:53:07.52', '2025-10-15 07:53:07.52', 'cmgrou6670011470586ipgylm', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrp46ud001i4705nvphpv0f', 'Palier applique', 'X21000923', NULL, '2025-10-15 07:55:00.132', '2025-10-15 07:55:00.132', 'cmgrnxlx5000g47059oyj4yuw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrp2sju00144705n8etw7im', 'Bras tendeur SE18', 'X56654', NULL, '2025-10-15 07:53:54.954', '2025-10-15 12:54:18.646', 'cmgrohigo000z4705q8yvpih0', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs08kjb00234705wc5tytxg', 'Cage d''écureuil de tension', 'W57719', NULL, '2025-10-15 13:06:20.278', '2025-10-15 13:06:20.278', 'cmgrzuwkj001u47057u8hej9u', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh3h3c82001f47zbvfmcu17d', 'Auget tôle', NULL, NULL, '2025-10-23 13:43:37.634', '2025-10-23 13:43:37.634', NULL, NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs0bive00274705zjmiuwzo', 'Rouleau1', 'X24001026', NULL, '2025-10-15 13:08:38.087', '2025-10-15 13:08:38.087', 'cmgroij2f00104705t6y33enk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs1tfza002m4705mbl0kwok', 'Bavette alimentation', 'P30W07069', NULL, '2025-10-15 13:50:33.766', '2025-10-15 13:50:33.766', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs1tvrs002n4705gpym7vel', 'Bavette centrage', 'P30W07052', NULL, '2025-10-15 13:50:54.232', '2025-10-15 13:50:54.232', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys8mjl001r47ff5f8z85fs', 'Attache rapide 19.05S', 'X10000565', NULL, '2025-10-20 06:56:49.185', '2025-10-20 06:56:49.185', 'cmgum1ih0000347ff7bsldmnv', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgytmhw0002547ffzobsmpaa', 'Moteur éléctrique', 'X50001591', NULL, '2025-10-20 07:35:35.925', '2025-10-20 07:35:35.925', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgyts8s9003747ffd1h8husf', 'Moteur éléctrique2', 'X50001596', NULL, '2025-10-20 07:40:04.088', '2025-10-20 07:40:04.088', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0rm8j004b47ffg2bh2ort', 'Réducteur1', 'X28896', NULL, '2025-10-20 10:55:32.179', '2025-10-20 10:55:32.179', 'cmgz0qu29004a47ffw1bmjr75', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0se74004o47ffnbbtu66b', 'Réducteur2', 'X15009329', NULL, '2025-10-20 10:56:08.416', '2025-10-20 10:56:08.416', 'cmgz0qu29004a47ffw1bmjr75', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh99o05y001547v7az12sk2n', 'Moteur à flasque', NULL, NULL, '2025-10-27 15:02:21.884', '2025-10-27 15:02:21.884', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0w66p004w47ffvj6xcxmo', 'Poulie1', 'X53433', NULL, '2025-10-20 10:59:04.657', '2025-10-20 10:59:26.075', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz17w9w006u47ffg6db710j', 'Courroie', 'X47067', NULL, '2025-10-20 11:08:11.684', '2025-10-20 11:08:11.684', 'cmgz17bpz006t47ff58i3j1e1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhaesmf5003v47v7bub04g9p', 'Joint à lèvre', 'J41800-RLX', 44.00, '2025-10-28 10:13:41.607', '2025-10-28 10:13:41.607', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbtjqbt0000475ultd24cp0', 'Segment d''arrêt - Circlips', 'S41800-SA2', 37.00, '2025-10-29 09:54:27.209', '2025-10-29 09:54:27.209', 'cmhalh6sa004h47v7y6pnqok2', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz18vw1007947ffr2wg86sa', 'Courroie2', 'X53480', NULL, '2025-10-20 11:08:57.84', '2025-10-20 11:08:57.84', 'cmgz17bpz006t47ff58i3j1e1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbv3kj9000t475uzbqpult7', 'Rondelle frein MB20', 'RDLMB20', 7.00, '2025-10-29 10:37:52.437', '2025-10-29 10:37:52.437', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhd56h02002a475uunel89e0', 'Manille', NULL, NULL, '2025-10-30 08:07:50.161', '2025-10-30 08:07:50.161', 'cmhd55caa0029475u1t4vg1i2', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhkr1ulr000147yv73imk2nx', 'Rondelle plate M4 RVS-A2', NULL, NULL, '2025-11-04 15:54:29.296', '2025-11-04 16:49:00.924', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh313676002d47s5li4e6qt9', 'Arbre', NULL, NULL, '2025-10-23 06:15:35.969', '2025-10-23 06:15:35.969', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh3jsgfr002n47zbadkkds7r', 'Bavette2', NULL, NULL, '2025-10-23 14:59:08.727', '2025-10-23 14:59:08.727', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrp3lhv00194705f1xp8j0m', 'Rouleau', 'X24001025', NULL, '2025-10-15 07:54:32.438', '2025-10-15 07:54:32.438', 'cmgroij2f00104705t6y33enk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs0nyk7002g4705rteyvw7x', 'Support rouleau inférieur', 'T30S06944', NULL, '2025-10-15 13:18:18.295', '2025-10-15 13:18:18.295', 'cmgs0kd5o002f47053b7n8tw6', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgum5zm0000547ffzg8ofiqr', 'Cage d''écureuil', 'W78517', NULL, '2025-10-17 08:55:43.753', '2025-10-17 08:55:43.753', 'cmgrzuwkj001u47057u8hej9u', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgum9bn4000847fffbazanc5', 'Arbre roue avant', 'H22907', NULL, '2025-10-17 08:58:19.312', '2025-10-17 08:58:19.312', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhaf1nsb004347v75uv8gmsi', 'Axe rouleau Promill', NULL, NULL, '2025-10-28 10:20:43.281', '2025-10-28 10:20:43.281', 'cmhaex3ca004247v78ymfpvpd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbu3due0007475ung88xfpm', 'Cuvette pour roulement HH 228310', 'C41800-CO2', 498.00, '2025-10-29 10:09:44.122', '2025-10-29 10:09:44.122', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgytqtc8002u47ffum90ylo5', 'Moteur éléctrique1', 'X50001593', NULL, '2025-10-20 07:38:57.415', '2025-10-20 07:38:57.415', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0xbx5005d47ffjkrafetg', 'Poulie2', 'X53446', NULL, '2025-10-20 10:59:58.743', '2025-10-20 10:59:58.743', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz1c9wx007h47ffr41untmr', 'Détecteur déport de bande', 'X23100', NULL, '2025-10-20 11:11:35.985', '2025-10-20 11:12:51.466', 'cmgum1wsl000447ffa109dtag', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbvyr210017475ubrey4eux', 'Ecrou de blocage KM20', 'ECRKM20A', 42.00, '2025-10-29 11:02:07.197', '2025-10-29 11:04:47.071', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh4xg7lb000347nkrtvqw3hi', 'Arbre Tapis émotteur', NULL, NULL, '2025-10-24 14:09:18.164', '2025-10-24 14:09:18.164', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhaagmno003647v7sfrgsb5v', 'COQUILLE nid d''abeille Promill', 'E41800ASN1P', 574.00, '2025-10-28 08:12:23.603', '2025-10-28 08:12:23.603', 'cmhaa5la2003447v7do7w3s0i', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhaf9o8j004947v7xomube6n', 'Flasque arrière rouleau Promill', 'F07700-001/5759701', 86.71, '2025-10-28 10:26:57.139', '2025-10-28 10:26:57.139', 'cmhaf6jaj004847v7cpq93sq5', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhafarpc004e47v7nqdy97xs', 'Flasque avant rouleau Promill', 'F07700-002/5759601', 115.30, '2025-10-28 10:27:48.288', '2025-10-28 10:27:48.288', 'cmhaf6jaj004847v7cpq93sq5', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbuapy5000e475utfiwfkcj', 'Cone pour roulement HH228340', 'C41800-CO2', 498.00, '2025-10-29 10:15:26.402', '2025-10-29 10:15:26.402', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhd4jjw9001q475u8x4i63jw', 'Graisseur 1/4 rouleaux Promill', NULL, NULL, '2025-10-30 07:50:00.797', '2025-10-30 07:50:00.797', 'cmhd48ipe001p475ul7ejiutq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhdb5hon002p475uif5ri4vu', 'Douille de serrage', NULL, NULL, '2025-10-30 10:55:02.086', '2025-10-30 10:55:02.086', 'cmhdb4mgx002o475udcnbd71h', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh31ejnw003b47s548rtk8b1', 'Arbre de commande', NULL, NULL, '2025-10-23 06:24:26.634', '2025-11-06 13:36:45.099', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrzvdmo001v47050tvf2z88', 'Cage d''écureuil de pied', 'W78515', NULL, '2025-10-15 12:56:04.801', '2025-10-15 13:05:49.946', 'cmgrzuwkj001u47057u8hej9u', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs14c7a002i4705t1w4qdfx', 'rouleau amortisseur avec axe', 'E1RS07058', NULL, '2025-10-15 13:31:02.469', '2025-10-15 13:31:02.469', 'cmgs13jjp002h4705rjqzz5lh', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgyruhgm000947ffmhhrqdrl', 'Galet avant chariot déverseur', 'H22698', NULL, '2025-10-20 06:45:49.386', '2025-10-20 06:45:49.386', 'cmgs1s4pv002j470567o60oqe', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgyrxcgu000a47ffxvcyyuwm', 'Palier BPF5', 'X21000919', NULL, '2025-10-20 06:48:02.91', '2025-10-20 06:48:02.91', 'cmgrnxlx5000g47059oyj4yuw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgyrzrbc000h47ffd670wu8j', 'Arbre roue arrière', 'H22908', NULL, '2025-10-20 06:49:55.463', '2025-10-20 06:49:55.463', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys0mgx000i47ffxbftvqt4', 'Galet arrière chariot déverseur', 'H22861', NULL, '2025-10-20 06:50:35.84', '2025-10-20 06:50:35.84', 'cmgs1s4pv002j470567o60oqe', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgysatbl001u47ffu55db8gg', 'Vérin éléctrique', 'X22754', NULL, '2025-10-20 06:58:31.282', '2025-10-20 06:58:31.282', 'cmgulzr7b000247ffpr2vsput', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgytuf06003k47ffdr8lvp13', 'Moteur éléctrique3', 'X50001598', NULL, '2025-10-20 07:41:45.434', '2025-10-20 07:41:45.434', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0y2aw005m47ff4zkjczei', 'Poulie3', 'X53450', NULL, '2025-10-20 11:00:32.936', '2025-10-20 11:00:32.936', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz112hd006e47ffvg37mkoq', 'Moyeu amovible2', 'X11F00653', NULL, '2025-10-20 11:02:53.136', '2025-10-20 11:02:53.136', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz1erci007r47ffybtdepul', 'Détecteur déport de bande1', 'X53294', NULL, '2025-10-20 11:13:31.891', '2025-10-20 11:13:31.891', 'cmgum1wsl000447ffa109dtag', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh3eadge001147zbworn1671', 'Bavette 2', NULL, NULL, '2025-10-23 12:25:06.974', '2025-10-23 12:25:06.974', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh3e95j4000k47zbx53n4tqv', 'Bavette1', NULL, NULL, '2025-10-23 12:24:10.047', '2025-10-23 14:58:45.061', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrnzbku000h4705qrj5eujb', 'Tambour de tête', 'H57305', NULL, '2025-10-15 07:23:13.346', '2025-10-15 07:23:13.346', 'cmgrnu6zc000f470565mc8hha', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs07df2001w4705ry79yvbo', 'Cage d''écureuil de pied de tension', 'W58372', NULL, '2025-10-15 13:05:24.397', '2025-10-15 13:05:24.397', 'cmgrzuwkj001u47057u8hej9u', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs1swl0002l4705gpyg1yyn', 'Galet releveur complet', 'W32440', NULL, '2025-10-15 13:50:08.628', '2025-10-15 13:50:08.628', 'cmgs1s4pv002j470567o60oqe', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys4a2s001847ffhqhz7zcd', 'Pignon moteur', 'H38143', NULL, '2025-10-20 06:53:26.404', '2025-10-20 06:54:46.835', 'cmgukvztv002s4705kqvqjtvg', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys6k6b001h47ffuq44ze37', 'Pignon récepteur', 'H47381', NULL, '2025-10-20 06:55:12.803', '2025-10-20 06:55:12.803', 'cmgukvztv002s4705kqvqjtvg', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys7anf001m47ff0ulcp092', 'Chaîne 19.05S', 'X10000564', NULL, '2025-10-20 06:55:47.115', '2025-10-20 06:56:20.844', 'cmgukxw26002t4705qz4ul929', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgysj6wn002347ffgq3f98dr', 'Détecteur mécanique', 'X60001690', NULL, '2025-10-20 07:05:02.134', '2025-10-20 07:05:02.134', 'cmgum1wsl000447ffa109dtag', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgytx2ul003x47ffhdpurtx5', 'Moteur éléctrique4', 'X50001600', NULL, '2025-10-20 07:43:49.676', '2025-10-20 07:43:49.676', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0yt33005v47ffy4p8d28z', 'Poulie4', 'X41745', NULL, '2025-10-20 11:01:07.646', '2025-10-20 11:01:07.646', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz10g67006547ffj28sqequ', 'Moyeu amovible1', 'X43888', NULL, '2025-10-20 11:02:24.223', '2025-10-20 11:02:24.223', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz11p1k006j47ffhjqgrnkp', 'Moyeu amovible3', 'X41739', NULL, '2025-10-20 11:03:22.375', '2025-10-20 11:03:22.375', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz128lz006o47ffwfgtag7e', 'Moyeu amovible4', 'X11F00765', NULL, '2025-10-20 11:03:47.735', '2025-10-20 11:03:47.735', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz516t7009n47fft3nfyt34', 'Tambour de tête1', 'H138830', NULL, '2025-10-20 12:54:57.211', '2025-10-20 12:54:57.211', 'cmgrnu6zc000f470565mc8hha', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhae5b7u003i47v7vb4qi81n', 'Joint torique R41', 'JTR41', 2.00, '2025-10-28 09:55:33.999', '2025-10-28 09:55:33.999', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhallrb1004i47v7855gvfpe', 'Segment d''étanchéïté', 'S41800-SA2', 37.00, '2025-10-28 13:24:18.658', '2025-10-28 13:24:56.572', 'cmhalh6sa004h47v7y6pnqok2', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys1osr000j47fftblpdpu2', 'Motoréducteur frein', 'X33959', NULL, '2025-10-20 06:51:25.485', '2025-10-20 06:52:25.744', NULL, NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys3ugw001147ffq33udxaw', 'Motoréducteur frein.', 'X108273', NULL, '2025-10-20 06:53:06.176', '2025-10-20 06:53:06.176', NULL, NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbuq97o000l475uf73oiot0', 'Entretoise de roulements', 'E41800-000', 67.00, '2025-10-29 10:27:31.208', '2025-10-29 10:27:31.208', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhd4s3u80020475uasnc0mqj', 'Crochet de levage', NULL, NULL, '2025-10-30 07:56:39.92', '2025-10-30 07:56:39.92', 'cmhd4r5bg001z475u0f4tm9yy', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhd4syhb0025475u4sv87dyf', 'Crochet de levage avec manille', NULL, NULL, '2025-10-30 07:57:19.63', '2025-10-30 07:57:19.63', 'cmhd4r5bg001z475u0f4tm9yy', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhdiuklf002u475uadws3h04', 'Courroie XPC', NULL, NULL, '2025-10-30 14:30:29.569', '2025-10-30 14:30:29.569', 'cmgz17bpz006t47ff58i3j1e1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh99m3e6000847v75h2m7czn', 'Réducteur emo', NULL, NULL, '2025-10-27 15:00:52.781', '2025-10-27 15:00:52.781', NULL, NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmizudzfy00021e2w2mtd9zv8', 'lame de godet 82', NULL, 192.00, '2025-12-10 10:04:09.262', '2025-12-10 10:05:37.177', 'cmizu3st800001e2waysco15j', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmizv8nzu00081e2wen6ur31b', 'Tapis', 'PF0165295', 3730.67, '2025-12-10 10:28:00.762', '2025-12-10 10:39:59.943', 'cmizup8cv00061e2w2rulkxsn', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'VIS (lame de godet) 82 M14 60mm tête fraisé', NULL, NULL, '2025-12-19 07:04:35.979', '2025-12-19 07:04:35.979', 'cmj025vi7000z1e2wyn3x6msv', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmjcpdwqs00161e2wu4juy4u2', 'Ecrou Ø 14', NULL, NULL, '2025-12-19 10:05:07.973', '2025-12-19 10:05:07.973', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhdattcv002f475ugw514oj3', 'Poulie 8', NULL, NULL, '2025-10-30 10:45:57.343', '2026-01-14 08:04:14.29', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdqrkez001o1e2wtslqeazi', 'Carter presse', NULL, NULL, '2026-01-14 08:11:13.308', '2026-01-14 08:11:13.308', 'cmkdqqh8w001n1e2wzxkamd1m', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdqw1tq00231e2wxou4eu8z', 'Ecrou HM12', NULL, NULL, '2026-01-14 08:14:42.494', '2026-01-14 08:14:42.494', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdrc535002e1e2wjuucdweq', 'Moteur entrainement Presse Promill', NULL, NULL, '2026-01-14 08:27:13.217', '2026-01-14 08:27:13.217', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdsbrz1002t1e2wqemldbr6', 'Vis HM 14x100', NULL, NULL, '2026-01-14 08:54:55.838', '2026-01-14 08:54:55.838', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdsdqjh00301e2wu2g4ljg2', 'Rondelle plate M24', NULL, NULL, '2026-01-14 08:56:27.294', '2026-01-14 08:56:27.294', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdsp16u003b1e2wetp787yf', 'Rondelle Grower W24', NULL, NULL, '2026-01-14 09:05:14.31', '2026-01-14 09:05:14.31', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdsrew1003m1e2wcuky5m77', 'Ecrou HM24', NULL, NULL, '2026-01-14 09:07:05.377', '2026-01-14 09:07:05.377', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdqun4a001s1e2wx123zdy1', 'Vis HM 12x35', NULL, NULL, '2026-01-14 08:13:36.778', '2026-01-14 09:15:22.132', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdt82ma003x1e2w9gkgwybf', 'Vis HM 12x30', NULL, NULL, '2026-01-14 09:20:02.626', '2026-01-14 09:20:02.626', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdtdh5p00441e2w0g6ye4v5', 'Rondelle Grower W12', NULL, NULL, '2026-01-14 09:24:14.75', '2026-01-14 09:24:14.75', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmke1nimf004f1e2wsurzoeet', 'Vis HM 8x16', NULL, NULL, '2026-01-14 13:16:00.135', '2026-01-14 13:16:00.135', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmke3hgbc004o1e2w02his9k3', 'Vis HM 24x100', NULL, NULL, '2026-01-14 14:07:16.44', '2026-01-14 14:07:16.44', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkp7xsz1006b1e2whn582enn', 'Arbre principal Presse Promill', 'APR 80 101101', NULL, '2026-01-22 08:57:25.741', '2026-01-22 08:57:25.741', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkp80cfi006k1e2wha1v14vv', 'Joint Viton', NULL, NULL, '2026-01-22 08:59:24.27', '2026-01-22 08:59:24.27', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkp84bs0006t1e2wkokejtyn', 'Roulement à rouleaux cylindriques', NULL, NULL, '2026-01-22 09:02:30.048', '2026-01-22 09:02:30.048', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkp8mbo900751e2w2746k94i', 'Circlips E260', '390 01126000', NULL, '2026-01-22 09:16:29.721', '2026-01-22 09:16:29.721', 'cmhalh6sa004h47v7y6pnqok2', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkpkaj4400091eq6p6accs62', 'Moyeu central', 'APR 80 101103', NULL, '2026-01-22 14:43:14.884', '2026-01-22 14:43:14.884', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkpkwfdh000g1eq6hpqdbqat', 'Roulement à rotule sur rouleaux', '320 41 220001', NULL, '2026-01-22 15:00:16.469', '2026-01-22 15:00:16.469', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkpnw6er000s1eq6k57kcnl8', 'Vis CHC M14x40', NULL, NULL, '2026-01-22 16:24:03.699', '2026-01-22 16:24:03.699', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkpo4gxz00121eq6o0ahmizg', 'Joint', '370 20 280000', NULL, '2026-01-22 16:30:30.6', '2026-01-22 16:30:30.6', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0nq1a004e1eq6v6ubxlfl', 'Palier tête E1 17', 'SNU516613', NULL, '2026-01-23 15:09:10.414', '2026-01-23 15:09:44.182', 'cmgrnxlx5000g47059oyj4yuw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0owbv004l1eq6pzlatzlr', 'Vis HM 14x50', NULL, NULL, '2026-01-23 15:10:05.228', '2026-01-23 15:10:05.228', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkqzl1oa002v1eq6erkt5544', 'BANDE E1 17', NULL, NULL, '2026-01-23 14:39:05.914', '2026-01-23 14:48:36.329', 'cmknus46z00551e2wf7zy706v', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0fwuo003o1eq69b1idlpw', 'Entretoise porte 2 joints', 'APR 80 101108', NULL, '2026-01-23 15:03:06', '2026-01-23 15:03:06', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0koqb003y1eq6a8cx8poy', 'Flasque porte 2 joints', 'APR 25 0304..', NULL, '2026-01-23 15:06:48.755', '2026-01-23 15:06:48.755', 'cmhaf6jaj004847v7cpq93sq5', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0nmsv00431eq6rcnozdes', 'Rondelle Grower W14', NULL, NULL, '2026-01-23 15:09:06.224', '2026-01-23 15:09:06.224', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr173u9004z1eq6tn4gw3h3', 'Rondelle frein MB44', NULL, NULL, '2026-01-23 15:24:14.77', '2026-01-23 15:24:14.77', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr20cpy005a1eq6nn5kmtys', 'Roulement E1 17', NULL, NULL, '2026-01-23 15:46:59.302', '2026-01-23 15:46:59.302', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr253dx005k1eq65vn7evdy', 'Ecrou HM 44T', NULL, NULL, '2026-01-23 15:50:40.485', '2026-01-23 15:50:40.485', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr25xz1005v1eq6i0fib4er', 'Manchon E1 17', NULL, NULL, '2026-01-23 15:51:20.125', '2026-01-23 15:51:20.125', 'cmkr24n3x005j1eq6s9xi6obl', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0qjw5004s1eq6pen63x7j', 'Arbre E1 17', NULL, NULL, '2026-01-23 15:11:22.422', '2026-01-23 15:55:47.227', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cl89d9641d47f52c5385f83d5c', 'test', 'four', 33.97, '2026-01-25 10:48:52', '2026-01-25 10:49:48', 'cl880ba34e5789668dd1c3affa', 'cmko9bmrd005m1e2w81v07kiz', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cl280f805cc3e6ff4b8bde95e4', 'testjjj', NULL, NULL, '2026-01-25 11:20:44', '2026-01-25 11:20:44', 'cl880ba34e5789668dd1c3affa', 'cmko9bmrd005m1e2w81v07kiz', '["cmko9bmrd005m1e2w81v07kiz","cmkpp4fb3001i1eq6qq74ul2i"]');
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrp1ry9001347052qn8q2yo', 'Lame raclette', 'P40S069915', NULL, '2025-10-15 07:53:07.52', '2025-10-15 07:53:07.52', 'cmgrou6670011470586ipgylm', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrp46ud001i4705nvphpv0f', 'Palier applique', 'X21000923', NULL, '2025-10-15 07:55:00.132', '2025-10-15 07:55:00.132', 'cmgrnxlx5000g47059oyj4yuw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrp2sju00144705n8etw7im', 'Bras tendeur SE18', 'X56654', NULL, '2025-10-15 07:53:54.954', '2025-10-15 12:54:18.646', 'cmgrohigo000z4705q8yvpih0', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs08kjb00234705wc5tytxg', 'Cage d''écureuil de tension', 'W57719', NULL, '2025-10-15 13:06:20.278', '2025-10-15 13:06:20.278', 'cmgrzuwkj001u47057u8hej9u', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh3h3c82001f47zbvfmcu17d', 'Auget tôle', NULL, NULL, '2025-10-23 13:43:37.634', '2025-10-23 13:43:37.634', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs0bive00274705zjmiuwzo', 'Rouleau1', 'X24001026', NULL, '2025-10-15 13:08:38.087', '2025-10-15 13:08:38.087', 'cmgroij2f00104705t6y33enk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs1tfza002m4705mbl0kwok', 'Bavette alimentation', 'P30W07069', NULL, '2025-10-15 13:50:33.766', '2025-10-15 13:50:33.766', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs1tvrs002n4705gpym7vel', 'Bavette centrage', 'P30W07052', NULL, '2025-10-15 13:50:54.232', '2025-10-15 13:50:54.232', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys8mjl001r47ff5f8z85fs', 'Attache rapide 19.05S', 'X10000565', NULL, '2025-10-20 06:56:49.185', '2025-10-20 06:56:49.185', 'cmgum1ih0000347ff7bsldmnv', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgytmhw0002547ffzobsmpaa', 'Moteur éléctrique', 'X50001591', NULL, '2025-10-20 07:35:35.925', '2025-10-20 07:35:35.925', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgyts8s9003747ffd1h8husf', 'Moteur éléctrique2', 'X50001596', NULL, '2025-10-20 07:40:04.088', '2025-10-20 07:40:04.088', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0rm8j004b47ffg2bh2ort', 'Réducteur1', 'X28896', NULL, '2025-10-20 10:55:32.179', '2025-10-20 10:55:32.179', 'cmgz0qu29004a47ffw1bmjr75', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0se74004o47ffnbbtu66b', 'Réducteur2', 'X15009329', NULL, '2025-10-20 10:56:08.416', '2025-10-20 10:56:08.416', 'cmgz0qu29004a47ffw1bmjr75', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh99o05y001547v7az12sk2n', 'Moteur à flasque', NULL, NULL, '2025-10-27 15:02:21.884', '2025-10-27 15:02:21.884', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0w66p004w47ffvj6xcxmo', 'Poulie1', 'X53433', NULL, '2025-10-20 10:59:04.657', '2025-10-20 10:59:26.075', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz17w9w006u47ffg6db710j', 'Courroie', 'X47067', NULL, '2025-10-20 11:08:11.684', '2025-10-20 11:08:11.684', 'cmgz17bpz006t47ff58i3j1e1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhaesmf5003v47v7bub04g9p', 'Joint à lèvre', 'J41800-RLX', 44.00, '2025-10-28 10:13:41.607', '2025-10-28 10:13:41.607', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbtjqbt0000475ultd24cp0', 'Segment d''arrêt - Circlips', 'S41800-SA2', 37.00, '2025-10-29 09:54:27.209', '2025-10-29 09:54:27.209', 'cmhalh6sa004h47v7y6pnqok2', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz18vw1007947ffr2wg86sa', 'Courroie2', 'X53480', NULL, '2025-10-20 11:08:57.84', '2025-10-20 11:08:57.84', 'cmgz17bpz006t47ff58i3j1e1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbv3kj9000t475uzbqpult7', 'Rondelle frein MB20', 'RDLMB20', 7.00, '2025-10-29 10:37:52.437', '2025-10-29 10:37:52.437', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhd56h02002a475uunel89e0', 'Manille', NULL, NULL, '2025-10-30 08:07:50.161', '2025-10-30 08:07:50.161', 'cmhd55caa0029475u1t4vg1i2', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhkr1ulr000147yv73imk2nx', 'Rondelle plate M4 RVS-A2', NULL, NULL, '2025-11-04 15:54:29.296', '2025-11-04 16:49:00.924', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh313676002d47s5li4e6qt9', 'Arbre', NULL, NULL, '2025-10-23 06:15:35.969', '2025-10-23 06:15:35.969', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh3jsgfr002n47zbadkkds7r', 'Bavette2', NULL, NULL, '2025-10-23 14:59:08.727', '2025-10-23 14:59:08.727', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrp3lhv00194705f1xp8j0m', 'Rouleau', 'X24001025', NULL, '2025-10-15 07:54:32.438', '2025-10-15 07:54:32.438', 'cmgroij2f00104705t6y33enk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs0nyk7002g4705rteyvw7x', 'Support rouleau inférieur', 'T30S06944', NULL, '2025-10-15 13:18:18.295', '2025-10-15 13:18:18.295', 'cmgs0kd5o002f47053b7n8tw6', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgum5zm0000547ffzg8ofiqr', 'Cage d''écureuil', 'W78517', NULL, '2025-10-17 08:55:43.753', '2025-10-17 08:55:43.753', 'cmgrzuwkj001u47057u8hej9u', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgum9bn4000847fffbazanc5', 'Arbre roue avant', 'H22907', NULL, '2025-10-17 08:58:19.312', '2025-10-17 08:58:19.312', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhaf1nsb004347v75uv8gmsi', 'Axe rouleau Promill', NULL, NULL, '2025-10-28 10:20:43.281', '2025-10-28 10:20:43.281', 'cmhaex3ca004247v78ymfpvpd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbu3due0007475ung88xfpm', 'Cuvette pour roulement HH 228310', 'C41800-CO2', 498.00, '2025-10-29 10:09:44.122', '2025-10-29 10:09:44.122', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgytqtc8002u47ffum90ylo5', 'Moteur éléctrique1', 'X50001593', NULL, '2025-10-20 07:38:57.415', '2025-10-20 07:38:57.415', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0xbx5005d47ffjkrafetg', 'Poulie2', 'X53446', NULL, '2025-10-20 10:59:58.743', '2025-10-20 10:59:58.743', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz1c9wx007h47ffr41untmr', 'Détecteur déport de bande', 'X23100', NULL, '2025-10-20 11:11:35.985', '2025-10-20 11:12:51.466', 'cmgum1wsl000447ffa109dtag', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbvyr210017475ubrey4eux', 'Ecrou de blocage KM20', 'ECRKM20A', 42.00, '2025-10-29 11:02:07.197', '2025-10-29 11:04:47.071', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh4xg7lb000347nkrtvqw3hi', 'Arbre Tapis émotteur', NULL, NULL, '2025-10-24 14:09:18.164', '2025-10-24 14:09:18.164', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhaagmno003647v7sfrgsb5v', 'COQUILLE nid d''abeille Promill', 'E41800ASN1P', 574.00, '2025-10-28 08:12:23.603', '2025-10-28 08:12:23.603', 'cmhaa5la2003447v7do7w3s0i', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhaf9o8j004947v7xomube6n', 'Flasque arrière rouleau Promill', 'F07700-001/5759701', 86.71, '2025-10-28 10:26:57.139', '2025-10-28 10:26:57.139', 'cmhaf6jaj004847v7cpq93sq5', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhafarpc004e47v7nqdy97xs', 'Flasque avant rouleau Promill', 'F07700-002/5759601', 115.30, '2025-10-28 10:27:48.288', '2025-10-28 10:27:48.288', 'cmhaf6jaj004847v7cpq93sq5', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbuapy5000e475utfiwfkcj', 'Cone pour roulement HH228340', 'C41800-CO2', 498.00, '2025-10-29 10:15:26.402', '2025-10-29 10:15:26.402', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhd4jjw9001q475u8x4i63jw', 'Graisseur 1/4 rouleaux Promill', NULL, NULL, '2025-10-30 07:50:00.797', '2025-10-30 07:50:00.797', 'cmhd48ipe001p475ul7ejiutq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhdb5hon002p475uif5ri4vu', 'Douille de serrage', NULL, NULL, '2025-10-30 10:55:02.086', '2025-10-30 10:55:02.086', 'cmhdb4mgx002o475udcnbd71h', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh31ejnw003b47s548rtk8b1', 'Arbre de commande', NULL, NULL, '2025-10-23 06:24:26.634', '2025-11-06 13:36:45.099', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrzvdmo001v47050tvf2z88', 'Cage d''écureuil de pied', 'W78515', NULL, '2025-10-15 12:56:04.801', '2025-10-15 13:05:49.946', 'cmgrzuwkj001u47057u8hej9u', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs14c7a002i4705t1w4qdfx', 'rouleau amortisseur avec axe', 'E1RS07058', NULL, '2025-10-15 13:31:02.469', '2025-10-15 13:31:02.469', 'cmgs13jjp002h4705rjqzz5lh', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgyruhgm000947ffmhhrqdrl', 'Galet avant chariot déverseur', 'H22698', NULL, '2025-10-20 06:45:49.386', '2025-10-20 06:45:49.386', 'cmgs1s4pv002j470567o60oqe', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgyrxcgu000a47ffxvcyyuwm', 'Palier BPF5', 'X21000919', NULL, '2025-10-20 06:48:02.91', '2025-10-20 06:48:02.91', 'cmgrnxlx5000g47059oyj4yuw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgyrzrbc000h47ffd670wu8j', 'Arbre roue arrière', 'H22908', NULL, '2025-10-20 06:49:55.463', '2025-10-20 06:49:55.463', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys0mgx000i47ffxbftvqt4', 'Galet arrière chariot déverseur', 'H22861', NULL, '2025-10-20 06:50:35.84', '2025-10-20 06:50:35.84', 'cmgs1s4pv002j470567o60oqe', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgysatbl001u47ffu55db8gg', 'Vérin éléctrique', 'X22754', NULL, '2025-10-20 06:58:31.282', '2025-10-20 06:58:31.282', 'cmgulzr7b000247ffpr2vsput', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgytuf06003k47ffdr8lvp13', 'Moteur éléctrique3', 'X50001598', NULL, '2025-10-20 07:41:45.434', '2025-10-20 07:41:45.434', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0y2aw005m47ff4zkjczei', 'Poulie3', 'X53450', NULL, '2025-10-20 11:00:32.936', '2025-10-20 11:00:32.936', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz112hd006e47ffvg37mkoq', 'Moyeu amovible2', 'X11F00653', NULL, '2025-10-20 11:02:53.136', '2025-10-20 11:02:53.136', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz1erci007r47ffybtdepul', 'Détecteur déport de bande1', 'X53294', NULL, '2025-10-20 11:13:31.891', '2025-10-20 11:13:31.891', 'cmgum1wsl000447ffa109dtag', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh3eadge001147zbworn1671', 'Bavette 2', NULL, NULL, '2025-10-23 12:25:06.974', '2025-10-23 12:25:06.974', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh3e95j4000k47zbx53n4tqv', 'Bavette1', NULL, NULL, '2025-10-23 12:24:10.047', '2025-10-23 14:58:45.061', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrnzbku000h4705qrj5eujb', 'Tambour de tête', 'H57305', NULL, '2025-10-15 07:23:13.346', '2025-10-15 07:23:13.346', 'cmgrnu6zc000f470565mc8hha', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs07df2001w4705ry79yvbo', 'Cage d''écureuil de pied de tension', 'W58372', NULL, '2025-10-15 13:05:24.397', '2025-10-15 13:05:24.397', 'cmgrzuwkj001u47057u8hej9u', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs1swl0002l4705gpyg1yyn', 'Galet releveur complet', 'W32440', NULL, '2025-10-15 13:50:08.628', '2025-10-15 13:50:08.628', 'cmgs1s4pv002j470567o60oqe', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys4a2s001847ffhqhz7zcd', 'Pignon moteur', 'H38143', NULL, '2025-10-20 06:53:26.404', '2025-10-20 06:54:46.835', 'cmgukvztv002s4705kqvqjtvg', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys6k6b001h47ffuq44ze37', 'Pignon récepteur', 'H47381', NULL, '2025-10-20 06:55:12.803', '2025-10-20 06:55:12.803', 'cmgukvztv002s4705kqvqjtvg', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys7anf001m47ff0ulcp092', 'Chaîne 19.05S', 'X10000564', NULL, '2025-10-20 06:55:47.115', '2025-10-20 06:56:20.844', 'cmgukxw26002t4705qz4ul929', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgysj6wn002347ffgq3f98dr', 'Détecteur mécanique', 'X60001690', NULL, '2025-10-20 07:05:02.134', '2025-10-20 07:05:02.134', 'cmgum1wsl000447ffa109dtag', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgytx2ul003x47ffhdpurtx5', 'Moteur éléctrique4', 'X50001600', NULL, '2025-10-20 07:43:49.676', '2025-10-20 07:43:49.676', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0yt33005v47ffy4p8d28z', 'Poulie4', 'X41745', NULL, '2025-10-20 11:01:07.646', '2025-10-20 11:01:07.646', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz10g67006547ffj28sqequ', 'Moyeu amovible1', 'X43888', NULL, '2025-10-20 11:02:24.223', '2025-10-20 11:02:24.223', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz11p1k006j47ffhjqgrnkp', 'Moyeu amovible3', 'X41739', NULL, '2025-10-20 11:03:22.375', '2025-10-20 11:03:22.375', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz128lz006o47ffwfgtag7e', 'Moyeu amovible4', 'X11F00765', NULL, '2025-10-20 11:03:47.735', '2025-10-20 11:03:47.735', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz516t7009n47fft3nfyt34', 'Tambour de tête1', 'H138830', NULL, '2025-10-20 12:54:57.211', '2025-10-20 12:54:57.211', 'cmgrnu6zc000f470565mc8hha', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhae5b7u003i47v7vb4qi81n', 'Joint torique R41', 'JTR41', 2.00, '2025-10-28 09:55:33.999', '2025-10-28 09:55:33.999', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhallrb1004i47v7855gvfpe', 'Segment d''étanchéïté', 'S41800-SA2', 37.00, '2025-10-28 13:24:18.658', '2025-10-28 13:24:56.572', 'cmhalh6sa004h47v7y6pnqok2', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys1osr000j47fftblpdpu2', 'Motoréducteur frein', 'X33959', NULL, '2025-10-20 06:51:25.485', '2025-10-20 06:52:25.744', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys3ugw001147ffq33udxaw', 'Motoréducteur frein.', 'X108273', NULL, '2025-10-20 06:53:06.176', '2025-10-20 06:53:06.176', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbuq97o000l475uf73oiot0', 'Entretoise de roulements', 'E41800-000', 67.00, '2025-10-29 10:27:31.208', '2025-10-29 10:27:31.208', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhd4s3u80020475uasnc0mqj', 'Crochet de levage', NULL, NULL, '2025-10-30 07:56:39.92', '2025-10-30 07:56:39.92', 'cmhd4r5bg001z475u0f4tm9yy', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhd4syhb0025475u4sv87dyf', 'Crochet de levage avec manille', NULL, NULL, '2025-10-30 07:57:19.63', '2025-10-30 07:57:19.63', 'cmhd4r5bg001z475u0f4tm9yy', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhdiuklf002u475uadws3h04', 'Courroie XPC', NULL, NULL, '2025-10-30 14:30:29.569', '2025-10-30 14:30:29.569', 'cmgz17bpz006t47ff58i3j1e1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh99m3e6000847v75h2m7czn', 'Réducteur emo', NULL, NULL, '2025-10-27 15:00:52.781', '2025-10-27 15:00:52.781', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmizudzfy00021e2w2mtd9zv8', 'lame de godet 82', NULL, 192.00, '2025-12-10 10:04:09.262', '2025-12-10 10:05:37.177', 'cmizu3st800001e2waysco15j', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmizv8nzu00081e2wen6ur31b', 'Tapis', 'PF0165295', 3730.67, '2025-12-10 10:28:00.762', '2025-12-10 10:39:59.943', 'cmizup8cv00061e2w2rulkxsn', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'VIS (lame de godet) 82 M14 60mm tête fraisé', NULL, NULL, '2025-12-19 07:04:35.979', '2025-12-19 07:04:35.979', 'cmj025vi7000z1e2wyn3x6msv', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmjcpdwqs00161e2wu4juy4u2', 'Ecrou Ø 14', NULL, NULL, '2025-12-19 10:05:07.973', '2025-12-19 10:05:07.973', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhdattcv002f475ugw514oj3', 'Poulie 8', NULL, NULL, '2025-10-30 10:45:57.343', '2026-01-14 08:04:14.29', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdqrkez001o1e2wtslqeazi', 'Carter presse', NULL, NULL, '2026-01-14 08:11:13.308', '2026-01-14 08:11:13.308', 'cmkdqqh8w001n1e2wzxkamd1m', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdqw1tq00231e2wxou4eu8z', 'Ecrou HM12', NULL, NULL, '2026-01-14 08:14:42.494', '2026-01-14 08:14:42.494', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdrc535002e1e2wjuucdweq', 'Moteur entrainement Presse Promill', NULL, NULL, '2026-01-14 08:27:13.217', '2026-01-14 08:27:13.217', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdsbrz1002t1e2wqemldbr6', 'Vis HM 14x100', NULL, NULL, '2026-01-14 08:54:55.838', '2026-01-14 08:54:55.838', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdsdqjh00301e2wu2g4ljg2', 'Rondelle plate M24', NULL, NULL, '2026-01-14 08:56:27.294', '2026-01-14 08:56:27.294', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdsp16u003b1e2wetp787yf', 'Rondelle Grower W24', NULL, NULL, '2026-01-14 09:05:14.31', '2026-01-14 09:05:14.31', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdsrew1003m1e2wcuky5m77', 'Ecrou HM24', NULL, NULL, '2026-01-14 09:07:05.377', '2026-01-14 09:07:05.377', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdqun4a001s1e2wx123zdy1', 'Vis HM 12x35', NULL, NULL, '2026-01-14 08:13:36.778', '2026-01-14 09:15:22.132', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdt82ma003x1e2w9gkgwybf', 'Vis HM 12x30', NULL, NULL, '2026-01-14 09:20:02.626', '2026-01-14 09:20:02.626', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdtdh5p00441e2w0g6ye4v5', 'Rondelle Grower W12', NULL, NULL, '2026-01-14 09:24:14.75', '2026-01-14 09:24:14.75', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmke1nimf004f1e2wsurzoeet', 'Vis HM 8x16', NULL, NULL, '2026-01-14 13:16:00.135', '2026-01-14 13:16:00.135', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmke3hgbc004o1e2w02his9k3', 'Vis HM 24x100', NULL, NULL, '2026-01-14 14:07:16.44', '2026-01-14 14:07:16.44', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkp7xsz1006b1e2whn582enn', 'Arbre principal Presse Promill', 'APR 80 101101', NULL, '2026-01-22 08:57:25.741', '2026-01-22 08:57:25.741', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkp80cfi006k1e2wha1v14vv', 'Joint Viton', NULL, NULL, '2026-01-22 08:59:24.27', '2026-01-22 08:59:24.27', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkp84bs0006t1e2wkokejtyn', 'Roulement à rouleaux cylindriques', NULL, NULL, '2026-01-22 09:02:30.048', '2026-01-22 09:02:30.048', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkp8mbo900751e2w2746k94i', 'Circlips E260', '390 01126000', NULL, '2026-01-22 09:16:29.721', '2026-01-22 09:16:29.721', 'cmhalh6sa004h47v7y6pnqok2', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkpkaj4400091eq6p6accs62', 'Moyeu central', 'APR 80 101103', NULL, '2026-01-22 14:43:14.884', '2026-01-22 14:43:14.884', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkpkwfdh000g1eq6hpqdbqat', 'Roulement à rotule sur rouleaux', '320 41 220001', NULL, '2026-01-22 15:00:16.469', '2026-01-22 15:00:16.469', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkpnw6er000s1eq6k57kcnl8', 'Vis CHC M14x40', NULL, NULL, '2026-01-22 16:24:03.699', '2026-01-22 16:24:03.699', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkpo4gxz00121eq6o0ahmizg', 'Joint', '370 20 280000', NULL, '2026-01-22 16:30:30.6', '2026-01-22 16:30:30.6', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0nq1a004e1eq6v6ubxlfl', 'Palier tête E1 17', 'SNU516613', NULL, '2026-01-23 15:09:10.414', '2026-01-23 15:09:44.182', 'cmgrnxlx5000g47059oyj4yuw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0owbv004l1eq6pzlatzlr', 'Vis HM 14x50', NULL, NULL, '2026-01-23 15:10:05.228', '2026-01-23 15:10:05.228', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkqzl1oa002v1eq6erkt5544', 'BANDE E1 17', NULL, NULL, '2026-01-23 14:39:05.914', '2026-01-23 14:48:36.329', 'cmknus46z00551e2wf7zy706v', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0fwuo003o1eq69b1idlpw', 'Entretoise porte 2 joints', 'APR 80 101108', NULL, '2026-01-23 15:03:06', '2026-01-23 15:03:06', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0koqb003y1eq6a8cx8poy', 'Flasque porte 2 joints', 'APR 25 0304..', NULL, '2026-01-23 15:06:48.755', '2026-01-23 15:06:48.755', 'cmhaf6jaj004847v7cpq93sq5', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0nmsv00431eq6rcnozdes', 'Rondelle Grower W14', NULL, NULL, '2026-01-23 15:09:06.224', '2026-01-23 15:09:06.224', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr173u9004z1eq6tn4gw3h3', 'Rondelle frein MB44', NULL, NULL, '2026-01-23 15:24:14.77', '2026-01-23 15:24:14.77', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr20cpy005a1eq6nn5kmtys', 'Roulement E1 17', NULL, NULL, '2026-01-23 15:46:59.302', '2026-01-23 15:46:59.302', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr253dx005k1eq65vn7evdy', 'Ecrou HM 44T', NULL, NULL, '2026-01-23 15:50:40.485', '2026-01-23 15:50:40.485', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr25xz1005v1eq6i0fib4er', 'Manchon E1 17', NULL, NULL, '2026-01-23 15:51:20.125', '2026-01-23 15:51:20.125', 'cmkr24n3x005j1eq6s9xi6obl', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0qjw5004s1eq6pen63x7j', 'Arbre E1 17', NULL, NULL, '2026-01-23 15:11:22.422', '2026-01-23 15:55:47.227', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cl89d9641d47f52c5385f83d5c', 'test', 'four', 33.97, '2026-01-25 10:48:52', '2026-01-25 10:49:48', 'cl880ba34e5789668dd1c3affa', 'cmko9bmrd005m1e2w81v07kiz');
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cl280f805cc3e6ff4b8bde95e4', 'testjjj', NULL, NULL, '2026-01-25 11:20:44', '2026-01-25 11:20:44', 'cl880ba34e5789668dd1c3affa', 'cmko9bmrd005m1e2w81v07kiz');
|
||||
|
||||
|
||||
--
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
26
migrations/Version20260309150000.php
Normal file
26
migrations/Version20260309150000.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 Version20260309150000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add quantity column to machine_piece_links table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE machine_piece_links ADD COLUMN IF NOT EXISTS quantity INTEGER NOT NULL DEFAULT 1');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE machine_piece_links DROP COLUMN IF EXISTS quantity');
|
||||
}
|
||||
}
|
||||
222
migrations/Version20260312170000.php
Normal file
222
migrations/Version20260312170000.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260312170000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create skeleton requirement tables (IF NOT EXISTS) and migrate JSON data from ModelType skeleton columns';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// ── Table creation (idempotent) ──────────────────────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS skeleton_piece_requirements (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"modeltypeid" VARCHAR(36) NOT NULL,
|
||||
"typepieceid" VARCHAR(36) NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS skeleton_product_requirements (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"modeltypeid" VARCHAR(36) NOT NULL,
|
||||
"typeproductid" VARCHAR(36) NOT NULL,
|
||||
"familycode" VARCHAR(255) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS skeleton_subcomponent_requirements (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"modeltypeid" VARCHAR(36) NOT NULL,
|
||||
alias VARCHAR(255) NOT NULL,
|
||||
"familycode" VARCHAR(255) NOT NULL,
|
||||
"typecomposantid" VARCHAR(36) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
// ── Indexes (idempotent) ─────────────────────────────────────────────
|
||||
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_piece_req_model ON skeleton_piece_requirements("modeltypeid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_piece_req_type ON skeleton_piece_requirements("typepieceid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_prod_req_model ON skeleton_product_requirements("modeltypeid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_prod_req_type ON skeleton_product_requirements("typeproductid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_sub_req_model ON skeleton_subcomponent_requirements("modeltypeid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_sub_req_typecomp ON skeleton_subcomponent_requirements("typecomposantid")');
|
||||
|
||||
// ── Foreign keys (idempotent via DO $$ block) ────────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_piece_model') THEN
|
||||
ALTER TABLE skeleton_piece_requirements
|
||||
ADD CONSTRAINT fk_skel_piece_model
|
||||
FOREIGN KEY ("modeltypeid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_piece_type') THEN
|
||||
ALTER TABLE skeleton_piece_requirements
|
||||
ADD CONSTRAINT fk_skel_piece_type
|
||||
FOREIGN KEY ("typepieceid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_prod_model') THEN
|
||||
ALTER TABLE skeleton_product_requirements
|
||||
ADD CONSTRAINT fk_skel_prod_model
|
||||
FOREIGN KEY ("modeltypeid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_prod_type') THEN
|
||||
ALTER TABLE skeleton_product_requirements
|
||||
ADD CONSTRAINT fk_skel_prod_type
|
||||
FOREIGN KEY ("typeproductid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_sub_model') THEN
|
||||
ALTER TABLE skeleton_subcomponent_requirements
|
||||
ADD CONSTRAINT fk_skel_sub_model
|
||||
FOREIGN KEY ("modeltypeid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_sub_typecomp') THEN
|
||||
ALTER TABLE skeleton_subcomponent_requirements
|
||||
ADD CONSTRAINT fk_skel_sub_typecomp
|
||||
FOREIGN KEY ("typecomposantid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// ── Data migration: componentSkeleton.pieces → skeleton_piece_requirements ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO skeleton_piece_requirements (id, "modeltypeid", "typepieceid", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
mt.id,
|
||||
(piece->>'typePieceId'),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM model_types mt,
|
||||
LATERAL jsonb_array_elements(mt.componentskeleton::jsonb->'pieces') WITH ORDINALITY AS t(piece, ordinality)
|
||||
WHERE mt.category = 'COMPONENT'
|
||||
AND mt.componentskeleton IS NOT NULL
|
||||
AND mt.componentskeleton::jsonb->'pieces' IS NOT NULL
|
||||
AND jsonb_array_length(mt.componentskeleton::jsonb->'pieces') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM skeleton_piece_requirements spr WHERE spr."modeltypeid" = mt.id)
|
||||
AND EXISTS (SELECT 1 FROM model_types ref WHERE ref.id = (piece->>'typePieceId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: componentSkeleton.products → skeleton_product_requirements ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO skeleton_product_requirements (id, "modeltypeid", "typeproductid", "familycode", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
mt.id,
|
||||
(product->>'typeProductId'),
|
||||
(product->>'familyCode'),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM model_types mt,
|
||||
LATERAL jsonb_array_elements(mt.componentskeleton::jsonb->'products') WITH ORDINALITY AS t(product, ordinality)
|
||||
WHERE mt.category = 'COMPONENT'
|
||||
AND mt.componentskeleton IS NOT NULL
|
||||
AND mt.componentskeleton::jsonb->'products' IS NOT NULL
|
||||
AND jsonb_array_length(mt.componentskeleton::jsonb->'products') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM skeleton_product_requirements spr WHERE spr."modeltypeid" = mt.id)
|
||||
AND EXISTS (SELECT 1 FROM model_types ref WHERE ref.id = (product->>'typeProductId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: pieceSkeleton.products → skeleton_product_requirements ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO skeleton_product_requirements (id, "modeltypeid", "typeproductid", "familycode", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
mt.id,
|
||||
(product->>'typeProductId'),
|
||||
(product->>'familyCode'),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM model_types mt,
|
||||
LATERAL jsonb_array_elements(mt.pieceskeleton::jsonb->'products') WITH ORDINALITY AS t(product, ordinality)
|
||||
WHERE mt.category = 'PIECE'
|
||||
AND mt.pieceskeleton IS NOT NULL
|
||||
AND mt.pieceskeleton::jsonb->'products' IS NOT NULL
|
||||
AND jsonb_array_length(mt.pieceskeleton::jsonb->'products') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM skeleton_product_requirements spr WHERE spr."modeltypeid" = mt.id)
|
||||
AND EXISTS (SELECT 1 FROM model_types ref WHERE ref.id = (product->>'typeProductId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: componentSkeleton.subcomponents → skeleton_subcomponent_requirements ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO skeleton_subcomponent_requirements (id, "modeltypeid", alias, "familycode", "typecomposantid", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
mt.id,
|
||||
COALESCE(sub->>'alias', ''),
|
||||
COALESCE(sub->>'familyCode', ''),
|
||||
NULLIF(sub->>'typeComposantId', ''),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM model_types mt,
|
||||
LATERAL jsonb_array_elements(mt.componentskeleton::jsonb->'subcomponents') WITH ORDINALITY AS t(sub, ordinality)
|
||||
WHERE mt.category = 'COMPONENT'
|
||||
AND mt.componentskeleton IS NOT NULL
|
||||
AND mt.componentskeleton::jsonb->'subcomponents' IS NOT NULL
|
||||
AND jsonb_array_length(mt.componentskeleton::jsonb->'subcomponents') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM skeleton_subcomponent_requirements ssr WHERE ssr."modeltypeid" = mt.id)
|
||||
AND (NULLIF(sub->>'typeComposantId', '') IS NULL OR EXISTS (SELECT 1 FROM model_types ref WHERE ref.id = (sub->>'typeComposantId')))
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS skeleton_subcomponent_requirements');
|
||||
$this->addSql('DROP TABLE IF EXISTS skeleton_product_requirements');
|
||||
$this->addSql('DROP TABLE IF EXISTS skeleton_piece_requirements');
|
||||
}
|
||||
}
|
||||
47
migrations/Version20260312171810.php
Normal file
47
migrations/Version20260312171810.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260312171810 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create piece_products join table and migrate data from Piece.productIds JSON column';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE IF NOT EXISTS piece_products (piece_id VARCHAR(36) NOT NULL, product_id VARCHAR(36) NOT NULL, PRIMARY KEY (piece_id, product_id))');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_87C835B5C40FCFA8 ON piece_products (piece_id)');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_87C835B54584665A ON piece_products (product_id)');
|
||||
$this->addSql('ALTER TABLE piece_products DROP CONSTRAINT IF EXISTS FK_87C835B5C40FCFA8');
|
||||
$this->addSql('ALTER TABLE piece_products ADD CONSTRAINT FK_87C835B5C40FCFA8 FOREIGN KEY (piece_id) REFERENCES pieces (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE piece_products DROP CONSTRAINT IF EXISTS FK_87C835B54584665A');
|
||||
$this->addSql('ALTER TABLE piece_products ADD CONSTRAINT FK_87C835B54584665A FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
|
||||
// Migrate Piece.productIds JSON array → piece_products join table
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO piece_products (piece_id, product_id)
|
||||
SELECT DISTINCT p.id, pid.value
|
||||
FROM pieces p,
|
||||
LATERAL jsonb_array_elements_text(p.productids::jsonb) AS pid(value)
|
||||
WHERE p.productids IS NOT NULL
|
||||
AND p.productids::jsonb != '[]'::jsonb
|
||||
AND jsonb_array_length(p.productids::jsonb) > 0
|
||||
AND EXISTS (SELECT 1 FROM products pr WHERE pr.id = pid.value)
|
||||
AND NOT EXISTS (SELECT 1 FROM piece_products pp WHERE pp.piece_id = p.id AND pp.product_id = pid.value)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE piece_products DROP CONSTRAINT IF EXISTS FK_87C835B5C40FCFA8');
|
||||
$this->addSql('ALTER TABLE piece_products DROP CONSTRAINT IF EXISTS FK_87C835B54584665A');
|
||||
$this->addSql('DROP TABLE IF EXISTS piece_products');
|
||||
}
|
||||
}
|
||||
35
migrations/Version20260312180000.php
Normal file
35
migrations/Version20260312180000.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Drop skeleton JSON columns from model_types — data now lives in
|
||||
* skeleton_piece_requirements, skeleton_product_requirements,
|
||||
* skeleton_subcomponent_requirements and custom_fields tables.
|
||||
*/
|
||||
final class Version20260312180000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop componentSkeleton, pieceSkeleton, productSkeleton JSON columns from model_types';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS componentskeleton');
|
||||
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS pieceskeleton');
|
||||
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS productskeleton');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS componentskeleton JSON DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS pieceskeleton JSON DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS productskeleton JSON DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
248
migrations/Version20260312190000.php
Normal file
248
migrations/Version20260312190000.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Create composant slot tables and migrate existing JSON data from composant.structure.
|
||||
*/
|
||||
final class Version20260312190000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create composant_piece_slots, composant_subcomponent_slots, composant_product_slots tables and migrate data from composant.structure JSON';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// ── Table creation (idempotent) ──────────────────────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS composant_piece_slots (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"composantid" VARCHAR(36) NOT NULL,
|
||||
"typepieceid" VARCHAR(36) DEFAULT NULL,
|
||||
"selectedpieceid" VARCHAR(36) DEFAULT NULL,
|
||||
quantity INT NOT NULL DEFAULT 1,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS composant_subcomponent_slots (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"composantid" VARCHAR(36) NOT NULL,
|
||||
alias VARCHAR(255) DEFAULT NULL,
|
||||
"familycode" VARCHAR(255) DEFAULT NULL,
|
||||
"typecomposantid" VARCHAR(36) DEFAULT NULL,
|
||||
"selectedcomposantid" VARCHAR(36) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS composant_product_slots (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"composantid" VARCHAR(36) NOT NULL,
|
||||
"typeproductid" VARCHAR(36) DEFAULT NULL,
|
||||
"selectedproductid" VARCHAR(36) DEFAULT NULL,
|
||||
"familycode" VARCHAR(255) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
// ── Indexes (idempotent) ─────────────────────────────────────────────
|
||||
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_piece_slot_composant ON composant_piece_slots("composantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_piece_slot_piece ON composant_piece_slots("selectedpieceid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_piece_slot_type ON composant_piece_slots("typepieceid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_sub_slot_composant ON composant_subcomponent_slots("composantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_sub_slot_typecomp ON composant_subcomponent_slots("typecomposantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_sub_slot_selected ON composant_subcomponent_slots("selectedcomposantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_prod_slot_composant ON composant_product_slots("composantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_prod_slot_type ON composant_product_slots("typeproductid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_prod_slot_selected ON composant_product_slots("selectedproductid")');
|
||||
|
||||
// ── Foreign keys (idempotent via DO $$ block) ────────────────────────
|
||||
|
||||
// composant_piece_slots FKs
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_piece_slot_composant') THEN
|
||||
ALTER TABLE composant_piece_slots
|
||||
ADD CONSTRAINT fk_comp_piece_slot_composant
|
||||
FOREIGN KEY ("composantid") REFERENCES composants (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_piece_slot_type') THEN
|
||||
ALTER TABLE composant_piece_slots
|
||||
ADD CONSTRAINT fk_comp_piece_slot_type
|
||||
FOREIGN KEY ("typepieceid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_piece_slot_piece') THEN
|
||||
ALTER TABLE composant_piece_slots
|
||||
ADD CONSTRAINT fk_comp_piece_slot_piece
|
||||
FOREIGN KEY ("selectedpieceid") REFERENCES pieces (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// composant_subcomponent_slots FKs
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_sub_slot_composant') THEN
|
||||
ALTER TABLE composant_subcomponent_slots
|
||||
ADD CONSTRAINT fk_comp_sub_slot_composant
|
||||
FOREIGN KEY ("composantid") REFERENCES composants (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_sub_slot_typecomp') THEN
|
||||
ALTER TABLE composant_subcomponent_slots
|
||||
ADD CONSTRAINT fk_comp_sub_slot_typecomp
|
||||
FOREIGN KEY ("typecomposantid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_sub_slot_selected') THEN
|
||||
ALTER TABLE composant_subcomponent_slots
|
||||
ADD CONSTRAINT fk_comp_sub_slot_selected
|
||||
FOREIGN KEY ("selectedcomposantid") REFERENCES composants (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// composant_product_slots FKs
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_prod_slot_composant') THEN
|
||||
ALTER TABLE composant_product_slots
|
||||
ADD CONSTRAINT fk_comp_prod_slot_composant
|
||||
FOREIGN KEY ("composantid") REFERENCES composants (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_prod_slot_type') THEN
|
||||
ALTER TABLE composant_product_slots
|
||||
ADD CONSTRAINT fk_comp_prod_slot_type
|
||||
FOREIGN KEY ("typeproductid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_prod_slot_selected') THEN
|
||||
ALTER TABLE composant_product_slots
|
||||
ADD CONSTRAINT fk_comp_prod_slot_selected
|
||||
FOREIGN KEY ("selectedproductid") REFERENCES products (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// ── Data migration: composant.structure.pieces → composant_piece_slots ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO composant_piece_slots (id, "composantid", "typepieceid", "selectedpieceid", quantity, position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
c.id,
|
||||
NULLIF(piece->'definition'->>'typePieceId', ''),
|
||||
NULLIF(piece->>'selectedPieceId', ''),
|
||||
1,
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM composants c,
|
||||
LATERAL jsonb_array_elements(c.structure::jsonb->'pieces') WITH ORDINALITY AS t(piece, ordinality)
|
||||
WHERE c.structure IS NOT NULL
|
||||
AND (c.structure::jsonb->'pieces') IS NOT NULL
|
||||
AND jsonb_array_length(c.structure::jsonb->'pieces') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM composant_piece_slots cps WHERE cps."composantid" = c.id)
|
||||
AND (NULLIF(piece->'definition'->>'typePieceId', '') IS NULL OR EXISTS (SELECT 1 FROM model_types mt WHERE mt.id = piece->'definition'->>'typePieceId'))
|
||||
AND (NULLIF(piece->>'selectedPieceId', '') IS NULL OR EXISTS (SELECT 1 FROM pieces p WHERE p.id = piece->>'selectedPieceId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: composant.structure.subcomponents → composant_subcomponent_slots ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO composant_subcomponent_slots (id, "composantid", alias, "familycode", "typecomposantid", "selectedcomposantid", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
c.id,
|
||||
COALESCE(sub->'definition'->>'alias', ''),
|
||||
COALESCE(sub->'definition'->>'familyCode', ''),
|
||||
NULLIF(sub->'definition'->>'typeComposantId', ''),
|
||||
NULLIF(sub->>'selectedComponentId', ''),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM composants c,
|
||||
LATERAL jsonb_array_elements(c.structure::jsonb->'subcomponents') WITH ORDINALITY AS t(sub, ordinality)
|
||||
WHERE c.structure IS NOT NULL
|
||||
AND (c.structure::jsonb->'subcomponents') IS NOT NULL
|
||||
AND jsonb_array_length(c.structure::jsonb->'subcomponents') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM composant_subcomponent_slots css WHERE css."composantid" = c.id)
|
||||
AND (NULLIF(sub->'definition'->>'typeComposantId', '') IS NULL OR EXISTS (SELECT 1 FROM model_types mt WHERE mt.id = sub->'definition'->>'typeComposantId'))
|
||||
AND (NULLIF(sub->>'selectedComponentId', '') IS NULL OR EXISTS (SELECT 1 FROM composants sc WHERE sc.id = sub->>'selectedComponentId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: composant.structure.products → composant_product_slots ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO composant_product_slots (id, "composantid", "typeproductid", "selectedproductid", "familycode", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
c.id,
|
||||
NULLIF(prod->'definition'->>'typeProductId', ''),
|
||||
NULLIF(prod->>'selectedProductId', ''),
|
||||
prod->'definition'->>'familyCode',
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM composants c,
|
||||
LATERAL jsonb_array_elements(c.structure::jsonb->'products') WITH ORDINALITY AS t(prod, ordinality)
|
||||
WHERE c.structure IS NOT NULL
|
||||
AND (c.structure::jsonb->'products') IS NOT NULL
|
||||
AND jsonb_array_length(c.structure::jsonb->'products') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM composant_product_slots cps WHERE cps."composantid" = c.id)
|
||||
AND (NULLIF(prod->'definition'->>'typeProductId', '') IS NULL OR EXISTS (SELECT 1 FROM model_types mt WHERE mt.id = prod->'definition'->>'typeProductId'))
|
||||
AND (NULLIF(prod->>'selectedProductId', '') IS NULL OR EXISTS (SELECT 1 FROM products p WHERE p.id = prod->>'selectedProductId'))
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS composant_product_slots');
|
||||
$this->addSql('DROP TABLE IF EXISTS composant_subcomponent_slots');
|
||||
$this->addSql('DROP TABLE IF EXISTS composant_piece_slots');
|
||||
}
|
||||
}
|
||||
30
migrations/Version20260312200000.php
Normal file
30
migrations/Version20260312200000.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Drop the legacy productIds JSON column from pieces table.
|
||||
* Data has been migrated to the piece_products join table.
|
||||
*/
|
||||
final class Version20260312200000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop legacy productIds JSON column from pieces table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS productids');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE pieces ADD COLUMN productids JSON DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
30
migrations/Version20260312210000.php
Normal file
30
migrations/Version20260312210000.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Drop the legacy structure JSON column from composants table.
|
||||
* Data has been migrated to composant_piece_slots, composant_subcomponent_slots, composant_product_slots tables.
|
||||
*/
|
||||
final class Version20260312210000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop legacy structure JSON column from composants table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE composants DROP COLUMN IF EXISTS structure');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE composants ADD COLUMN structure JSON DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
118
migrations/Version20260313124029.php
Normal file
118
migrations/Version20260313124029.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Create piece_product_slots table (mirroring composant_product_slots)
|
||||
* and add version columns to composants, pieces, products.
|
||||
*/
|
||||
final class Version20260313124029 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create piece_product_slots table, add version columns to composants/pieces/products, migrate piece_products data';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// ── Create piece_product_slots table (idempotent) ─────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS piece_product_slots (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"pieceid" VARCHAR(36) NOT NULL,
|
||||
"typeproductid" VARCHAR(36) DEFAULT NULL,
|
||||
"selectedproductid" VARCHAR(36) DEFAULT NULL,
|
||||
"familycode" VARCHAR(255) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
// ── Indexes (idempotent) ──────────────────────────────────────────────
|
||||
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_prod_slot_piece ON piece_product_slots ("pieceid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_prod_slot_type ON piece_product_slots ("typeproductid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_prod_slot_selected ON piece_product_slots ("selectedproductid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_product_slots_piece_pos ON piece_product_slots ("pieceid", position)');
|
||||
|
||||
// ── Foreign keys (idempotent via DO $$ block) ─────────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_piece_prod_slot_piece') THEN
|
||||
ALTER TABLE piece_product_slots
|
||||
ADD CONSTRAINT fk_piece_prod_slot_piece
|
||||
FOREIGN KEY ("pieceid") REFERENCES pieces (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_piece_prod_slot_type') THEN
|
||||
ALTER TABLE piece_product_slots
|
||||
ADD CONSTRAINT fk_piece_prod_slot_type
|
||||
FOREIGN KEY ("typeproductid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_piece_prod_slot_selected') THEN
|
||||
ALTER TABLE piece_product_slots
|
||||
ADD CONSTRAINT fk_piece_prod_slot_selected
|
||||
FOREIGN KEY ("selectedproductid") REFERENCES products (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// ── Add version columns (idempotent) ─────────────────────────────────
|
||||
|
||||
$this->addSql('ALTER TABLE composants ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
|
||||
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
|
||||
$this->addSql('ALTER TABLE products ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
|
||||
|
||||
// ── Data migration: piece_products → piece_product_slots ─────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'piece_products') THEN
|
||||
INSERT INTO piece_product_slots (id, "pieceid", "typeproductid", "selectedproductid", "familycode", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
pp.piece_id,
|
||||
p.typeproductid,
|
||||
pp.product_id,
|
||||
NULL,
|
||||
ROW_NUMBER() OVER (PARTITION BY pp.piece_id ORDER BY pp.product_id) - 1,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM piece_products pp
|
||||
JOIN products p ON p.id = pp.product_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM piece_product_slots pps
|
||||
WHERE pps."pieceid" = pp.piece_id AND pps."selectedproductid" = pp.product_id
|
||||
);
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS piece_product_slots');
|
||||
$this->addSql('ALTER TABLE composants DROP COLUMN IF EXISTS version');
|
||||
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS version');
|
||||
$this->addSql('ALTER TABLE products DROP COLUMN IF EXISTS version');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
59
src/Controller/ComposantPieceSlotController.php
Normal file
59
src/Controller/ComposantPieceSlotController.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\ComposantPieceSlot;
|
||||
use App\Entity\Piece;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/composant-piece-slots')]
|
||||
class ComposantPieceSlotController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}', name: 'composant_piece_slot_patch', methods: ['PATCH'])]
|
||||
public function patch(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$slot = $this->entityManager->find(ComposantPieceSlot::class, $id);
|
||||
if (!$slot) {
|
||||
return $this->json(['success' => false, 'error' => 'Slot not found.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
|
||||
}
|
||||
|
||||
if (array_key_exists('quantity', $payload)) {
|
||||
$slot->setQuantity(max(1, (int) $payload['quantity']));
|
||||
}
|
||||
|
||||
if (array_key_exists('selectedPieceId', $payload)) {
|
||||
if (null === $payload['selectedPieceId']) {
|
||||
$slot->setSelectedPiece(null);
|
||||
} else {
|
||||
$piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']);
|
||||
$slot->setSelectedPiece($piece);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'id' => $slot->getId(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
54
src/Controller/ComposantProductSlotController.php
Normal file
54
src/Controller/ComposantProductSlotController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\ComposantProductSlot;
|
||||
use App\Entity\Product;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/composant-product-slots')]
|
||||
class ComposantProductSlotController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}', name: 'composant_product_slot_patch', methods: ['PATCH'])]
|
||||
public function patch(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$slot = $this->entityManager->find(ComposantProductSlot::class, $id);
|
||||
if (!$slot) {
|
||||
return $this->json(['success' => false, 'error' => 'Slot not found.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
|
||||
}
|
||||
|
||||
if (array_key_exists('selectedProductId', $payload)) {
|
||||
if (null === $payload['selectedProductId']) {
|
||||
$slot->setSelectedProduct(null);
|
||||
} else {
|
||||
$product = $this->entityManager->find(Product::class, $payload['selectedProductId']);
|
||||
$slot->setSelectedProduct($product);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'id' => $slot->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
54
src/Controller/ComposantSubcomponentSlotController.php
Normal file
54
src/Controller/ComposantSubcomponentSlotController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\ComposantSubcomponentSlot;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/composant-subcomponent-slots')]
|
||||
class ComposantSubcomponentSlotController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}', name: 'composant_subcomponent_slot_patch', methods: ['PATCH'])]
|
||||
public function patch(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$slot = $this->entityManager->find(ComposantSubcomponentSlot::class, $id);
|
||||
if (!$slot) {
|
||||
return $this->json(['success' => false, 'error' => 'Slot not found.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
|
||||
}
|
||||
|
||||
if (array_key_exists('selectedComposantId', $payload)) {
|
||||
if (null === $payload['selectedComposantId']) {
|
||||
$slot->setSelectedComposant(null);
|
||||
} else {
|
||||
$composant = $this->entityManager->find(Composant::class, $payload['selectedComposantId']);
|
||||
$slot->setSelectedComposant($composant);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'id' => $slot->getId(),
|
||||
'selectedComposantId' => $slot->getSelectedComposant()?->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
@@ -17,14 +17,7 @@ class HealthCheckController extends AbstractController
|
||||
#[Route('/api/health', name: 'api_health', methods: ['GET'])]
|
||||
public function __invoke(Connection $connection): JsonResponse
|
||||
{
|
||||
$version = '0.0.0';
|
||||
$versionFile = $this->getParameter('kernel.project_dir').'/VERSION';
|
||||
if (file_exists($versionFile)) {
|
||||
$version = trim(file_get_contents($versionFile));
|
||||
}
|
||||
|
||||
$dbOk = false;
|
||||
$dbLatency = null;
|
||||
$dbOk = false;
|
||||
|
||||
try {
|
||||
$start = hrtime(true);
|
||||
@@ -32,22 +25,33 @@ class HealthCheckController extends AbstractController
|
||||
$dbLatency = round((hrtime(true) - $start) / 1e6, 1);
|
||||
$dbOk = true;
|
||||
} catch (Throwable) {
|
||||
$dbLatency = null;
|
||||
}
|
||||
|
||||
$healthy = $dbOk;
|
||||
$data = ['status' => $healthy ? 'ok' : 'degraded'];
|
||||
|
||||
return $this->json([
|
||||
'status' => $healthy ? 'ok' : 'degraded',
|
||||
'version' => $version,
|
||||
'timestamp' => new DateTimeImmutable()->format(DateTimeInterface::ATOM),
|
||||
'php' => PHP_VERSION,
|
||||
'checks' => [
|
||||
'database' => [
|
||||
'status' => $dbOk ? 'ok' : 'down',
|
||||
'latency_ms' => $dbLatency,
|
||||
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),
|
||||
], $healthy ? 200 : 503);
|
||||
'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);
|
||||
}
|
||||
@@ -241,6 +242,7 @@ class MachineStructureController extends AbstractController
|
||||
$newLink->setNameOverride($link->getNameOverride());
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
$newLink->setPrixOverride($link->getPrixOverride());
|
||||
$newLink->setQuantity($link->getQuantity());
|
||||
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($componentLinkMap[$parent->getId()])) {
|
||||
@@ -394,6 +396,11 @@ class MachineStructureController extends AbstractController
|
||||
|
||||
$this->applyOverrides($link, $entry['overrides'] ?? null);
|
||||
|
||||
if (!isset($entry['parentComponentLinkId']) && !isset($entry['parentLinkId'])) {
|
||||
$quantity = isset($entry['quantity']) ? (int) $entry['quantity'] : $link->getQuantity();
|
||||
$link->setQuantity(max(1, $quantity));
|
||||
}
|
||||
|
||||
$pendingParents[$linkId] = $this->resolveIdentifier($entry, [
|
||||
'parentComponentLinkId',
|
||||
'parentLinkId',
|
||||
@@ -572,7 +579,7 @@ class MachineStructureController extends AbstractController
|
||||
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
|
||||
'customFields' => $this->normalizeCustomFields($machine->getCustomFields()),
|
||||
'documents' => null,
|
||||
'customFieldValues' => null,
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -635,10 +642,31 @@ class MachineStructureController extends AbstractController
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'quantity' => $this->resolvePieceQuantity($link),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
private function resolvePieceQuantity(MachinePieceLink $link): int
|
||||
{
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
if (!$parentLink) {
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
$composant = $parentLink->getComposant();
|
||||
$piece = $link->getPiece();
|
||||
|
||||
foreach ($composant->getPieceSlots() as $slot) {
|
||||
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
|
||||
return $slot->getQuantity();
|
||||
}
|
||||
}
|
||||
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
private function normalizeProductLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineProductLink $link): array {
|
||||
@@ -670,6 +698,7 @@ class MachineStructureController extends AbstractController
|
||||
'typeComposant' => $this->normalizeModelType($type),
|
||||
'productId' => $composant->getProduct()?->getId(),
|
||||
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
|
||||
'structure' => $this->buildStructureFromSlots($composant),
|
||||
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [],
|
||||
@@ -677,6 +706,48 @@ class MachineStructureController extends AbstractController
|
||||
];
|
||||
}
|
||||
|
||||
private function buildStructureFromSlots(Composant $composant): array
|
||||
{
|
||||
$pieces = [];
|
||||
foreach ($composant->getPieceSlots() as $slot) {
|
||||
$pieceData = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
];
|
||||
if ($slot->getSelectedPiece()) {
|
||||
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece());
|
||||
}
|
||||
$pieces[] = $pieceData;
|
||||
}
|
||||
|
||||
$subcomponents = [];
|
||||
foreach ($composant->getSubcomponentSlots() as $slot) {
|
||||
$subcomponents[] = [
|
||||
'alias' => $slot->getAlias(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'typeComposantId' => $slot->getTypeComposant()?->getId(),
|
||||
'selectedComponentId' => $slot->getSelectedComposant()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
$products = [];
|
||||
foreach ($composant->getProductSlots() as $slot) {
|
||||
$products[] = [
|
||||
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'pieces' => $pieces,
|
||||
'subcomponents' => $subcomponents,
|
||||
'products' => $products,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePiece(Piece $piece): array
|
||||
{
|
||||
$type = $piece->getTypePiece();
|
||||
@@ -699,16 +770,19 @@ class MachineStructureController extends AbstractController
|
||||
|
||||
private function normalizeProduct(Product $product): array
|
||||
{
|
||||
$type = $product->getTypeProduct();
|
||||
|
||||
return [
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'supplierPrice' => $product->getSupplierPrice(),
|
||||
'typeProductId' => $product->getTypeProduct()?->getId(),
|
||||
'typeProduct' => $this->normalizeModelType($product->getTypeProduct()),
|
||||
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => [],
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'supplierPrice' => $product->getSupplierPrice(),
|
||||
'typeProductId' => $type?->getId(),
|
||||
'typeProduct' => $this->normalizeModelType($type),
|
||||
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getProductCustomFields()) : [],
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($product->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -772,7 +846,7 @@ class MachineStructureController extends AbstractController
|
||||
if (!$cfv instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
$cf = $cfv->getCustomField();
|
||||
$cf = $cfv->getCustomField();
|
||||
$items[] = [
|
||||
'id' => $cfv->getId(),
|
||||
'value' => $cfv->getValue(),
|
||||
|
||||
74
src/Controller/ModelTypeSyncController.php
Normal file
74
src/Controller/ModelTypeSyncController.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use App\Service\ModelTypeSyncService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/model_types/{id}')]
|
||||
final class ModelTypeSyncController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
private readonly ModelTypeSyncService $syncService,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/sync-preview', name: 'api_model_type_sync_preview', methods: ['POST'])]
|
||||
public function preview(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$modelType = $this->modelTypes->find($id);
|
||||
|
||||
if (!$modelType) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Catégorie introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$structure = $body['structure'] ?? [];
|
||||
|
||||
$result = $this->syncService->preview($modelType, $structure);
|
||||
|
||||
return new JsonResponse($result);
|
||||
}
|
||||
|
||||
#[Route('/sync', name: 'api_model_type_sync', methods: ['POST'])]
|
||||
public function sync(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$modelType = $this->modelTypes->find($id);
|
||||
|
||||
if (!$modelType) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Catégorie introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$confirmation = new SyncConfirmation(
|
||||
confirmDeletions: $body['confirmDeletions'] ?? false,
|
||||
confirmTypeChanges: $body['confirmTypeChanges'] ?? false,
|
||||
);
|
||||
|
||||
$result = $this->em->wrapInTransaction(function () use ($modelType, $confirmation) {
|
||||
return $this->syncService->execute($modelType, $confirmation);
|
||||
});
|
||||
|
||||
return new JsonResponse($result);
|
||||
}
|
||||
}
|
||||
@@ -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!']);
|
||||
}
|
||||
}
|
||||
13
src/DTO/SyncConfirmation.php
Normal file
13
src/DTO/SyncConfirmation.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
class SyncConfirmation
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $confirmDeletions = false,
|
||||
public readonly bool $confirmTypeChanges = false,
|
||||
) {}
|
||||
}
|
||||
27
src/DTO/SyncExecutionResult.php
Normal file
27
src/DTO/SyncExecutionResult.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
class SyncExecutionResult implements JsonSerializable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $itemsUpdated,
|
||||
public readonly array $additions = [],
|
||||
public readonly array $deletions = [],
|
||||
public readonly array $modifications = [],
|
||||
) {}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'itemsUpdated' => $this->itemsUpdated,
|
||||
'additions' => $this->additions,
|
||||
'deletions' => $this->deletions,
|
||||
'modifications' => $this->modifications,
|
||||
];
|
||||
}
|
||||
}
|
||||
38
src/DTO/SyncPreviewResult.php
Normal file
38
src/DTO/SyncPreviewResult.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
class SyncPreviewResult implements JsonSerializable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $modelTypeId,
|
||||
public readonly string $category,
|
||||
public readonly int $itemCount,
|
||||
public readonly array $additions = [],
|
||||
public readonly array $deletions = [],
|
||||
public readonly array $modifications = [],
|
||||
) {}
|
||||
|
||||
public function hasImpact(): bool
|
||||
{
|
||||
return array_sum($this->additions) > 0
|
||||
|| array_sum($this->deletions) > 0
|
||||
|| array_sum($this->modifications) > 0;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'modelTypeId' => $this->modelTypeId,
|
||||
'category' => $this->category,
|
||||
'itemCount' => $this->itemCount,
|
||||
'additions' => $this->additions,
|
||||
'deletions' => $this->deletions,
|
||||
'modifications' => $this->modifications,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'])]
|
||||
@@ -63,10 +67,6 @@ class Composant
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $prix = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
private ?array $structure = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'composants')]
|
||||
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
@@ -109,6 +109,31 @@ class Composant
|
||||
#[ORM\OneToMany(mappedBy: 'composant', targetEntity: MachineComponentLink::class)]
|
||||
private Collection $machineLinks;
|
||||
|
||||
/**
|
||||
* @var Collection<int, ComposantPieceSlot>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: ComposantPieceSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $pieceSlots;
|
||||
|
||||
/**
|
||||
* @var Collection<int, ComposantSubcomponentSlot>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: ComposantSubcomponentSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $subcomponentSlots;
|
||||
|
||||
/**
|
||||
* @var Collection<int, ComposantProductSlot>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: ComposantProductSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $productSlots;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Groups(['composant:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['composant:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -119,40 +144,15 @@ 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;
|
||||
$this->pieceSlots = new ArrayCollection();
|
||||
$this->subcomponentSlots = new ArrayCollection();
|
||||
$this->productSlots = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
@@ -203,18 +203,6 @@ class Composant
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStructure(): ?array
|
||||
{
|
||||
return $this->structure;
|
||||
}
|
||||
|
||||
public function setStructure(?array $structure): static
|
||||
{
|
||||
$this->structure = $structure;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeComposant(): ?ModelType
|
||||
{
|
||||
return $this->typeComposant;
|
||||
@@ -295,18 +283,143 @@ class Composant
|
||||
return $this->customFieldValues;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
/**
|
||||
* @return Collection<int, ComposantPieceSlot>
|
||||
*/
|
||||
public function getPieceSlots(): Collection
|
||||
{
|
||||
return $this->createdAt;
|
||||
return $this->pieceSlots;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
public function addPieceSlot(ComposantPieceSlot $pieceSlot): static
|
||||
{
|
||||
return $this->updatedAt;
|
||||
if (!$this->pieceSlots->contains($pieceSlot)) {
|
||||
$this->pieceSlots->add($pieceSlot);
|
||||
$pieceSlot->setComposant($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
public function removePieceSlot(ComposantPieceSlot $pieceSlot): static
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
$this->pieceSlots->removeElement($pieceSlot);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ComposantSubcomponentSlot>
|
||||
*/
|
||||
public function getSubcomponentSlots(): Collection
|
||||
{
|
||||
return $this->subcomponentSlots;
|
||||
}
|
||||
|
||||
public function addSubcomponentSlot(ComposantSubcomponentSlot $subcomponentSlot): static
|
||||
{
|
||||
if (!$this->subcomponentSlots->contains($subcomponentSlot)) {
|
||||
$this->subcomponentSlots->add($subcomponentSlot);
|
||||
$subcomponentSlot->setComposant($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSubcomponentSlot(ComposantSubcomponentSlot $subcomponentSlot): static
|
||||
{
|
||||
$this->subcomponentSlots->removeElement($subcomponentSlot);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ComposantProductSlot>
|
||||
*/
|
||||
public function getProductSlots(): Collection
|
||||
{
|
||||
return $this->productSlots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual property — rebuilds the legacy structure JSON from slot tables.
|
||||
*
|
||||
* @return null|array{pieces: list<array<string, mixed>>, products: list<array<string, mixed>>, subcomponents: list<array<string, mixed>>}
|
||||
*/
|
||||
#[Groups(['composant:read'])]
|
||||
public function getStructure(): ?array
|
||||
{
|
||||
$pieces = [];
|
||||
foreach ($this->pieceSlots as $slot) {
|
||||
$pieces[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$products = [];
|
||||
foreach ($this->productSlots as $slot) {
|
||||
$products[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$subcomponents = [];
|
||||
foreach ($this->subcomponentSlots as $slot) {
|
||||
$subcomponents[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'alias' => $slot->getAlias(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'typeComposantId' => $slot->getTypeComposant()?->getId(),
|
||||
'selectedComponentId' => $slot->getSelectedComposant()?->getId(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($pieces) && empty($products) && empty($subcomponents)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'pieces' => $pieces,
|
||||
'products' => $products,
|
||||
'subcomponents' => $subcomponents,
|
||||
];
|
||||
}
|
||||
|
||||
public function addProductSlot(ComposantProductSlot $productSlot): static
|
||||
{
|
||||
if (!$this->productSlots->contains($productSlot)) {
|
||||
$this->productSlots->add($productSlot);
|
||||
$productSlot->setComposant($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeProductSlot(ComposantProductSlot $productSlot): static
|
||||
{
|
||||
$this->productSlots->removeElement($productSlot);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function incrementVersion(): static
|
||||
{
|
||||
++$this->version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
112
src/Entity/ComposantPieceSlot.php
Normal file
112
src/Entity/ComposantPieceSlot.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'composant_piece_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class ComposantPieceSlot
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'pieceSlots')]
|
||||
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Composant $composant;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typePieceId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typePiece = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Piece::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedPieceId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Piece $selectedPiece = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
private int $quantity = 1;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getComposant(): Composant
|
||||
{
|
||||
return $this->composant;
|
||||
}
|
||||
|
||||
public function setComposant(Composant $composant): static
|
||||
{
|
||||
$this->composant = $composant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypePiece(): ?ModelType
|
||||
{
|
||||
return $this->typePiece;
|
||||
}
|
||||
|
||||
public function setTypePiece(?ModelType $typePiece): static
|
||||
{
|
||||
$this->typePiece = $typePiece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSelectedPiece(): ?Piece
|
||||
{
|
||||
return $this->selectedPiece;
|
||||
}
|
||||
|
||||
public function setSelectedPiece(?Piece $selectedPiece): static
|
||||
{
|
||||
$this->selectedPiece = $selectedPiece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getQuantity(): int
|
||||
{
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function setQuantity(int $quantity): static
|
||||
{
|
||||
$this->quantity = $quantity;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
112
src/Entity/ComposantProductSlot.php
Normal file
112
src/Entity/ComposantProductSlot.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'composant_product_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class ComposantProductSlot
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'productSlots')]
|
||||
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Composant $composant;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeProduct = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Product::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Product $selectedProduct = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getComposant(): Composant
|
||||
{
|
||||
return $this->composant;
|
||||
}
|
||||
|
||||
public function setComposant(Composant $composant): static
|
||||
{
|
||||
$this->composant = $composant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ?ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(?ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSelectedProduct(): ?Product
|
||||
{
|
||||
return $this->selectedProduct;
|
||||
}
|
||||
|
||||
public function setSelectedProduct(?Product $selectedProduct): static
|
||||
{
|
||||
$this->selectedProduct = $selectedProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): ?string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(?string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
127
src/Entity/ComposantSubcomponentSlot.php
Normal file
127
src/Entity/ComposantSubcomponentSlot.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'composant_subcomponent_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class ComposantSubcomponentSlot
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'subcomponentSlots')]
|
||||
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Composant $composant;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $alias = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeComposant = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Composant::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Composant $selectedComposant = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getComposant(): Composant
|
||||
{
|
||||
return $this->composant;
|
||||
}
|
||||
|
||||
public function setComposant(Composant $composant): static
|
||||
{
|
||||
$this->composant = $composant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAlias(): ?string
|
||||
{
|
||||
return $this->alias;
|
||||
}
|
||||
|
||||
public function setAlias(?string $alias): static
|
||||
{
|
||||
$this->alias = $alias;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): ?string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(?string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeComposant(): ?ModelType
|
||||
{
|
||||
return $this->typeComposant;
|
||||
}
|
||||
|
||||
public function setTypeComposant(?ModelType $typeComposant): static
|
||||
{
|
||||
$this->typeComposant = $typeComposant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSelectedComposant(): ?Composant
|
||||
{
|
||||
return $this->selectedComposant;
|
||||
}
|
||||
|
||||
public function setSelectedComposant(?Composant $selectedComposant): static
|
||||
{
|
||||
$this->selectedComposant = $selectedComposant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -209,18 +185,39 @@ class CustomField
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
public function getTypeComposant(): ?ModelType
|
||||
{
|
||||
return $this->createdAt;
|
||||
return $this->typeComposant;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
public function setTypeComposant(?ModelType $typeComposant): static
|
||||
{
|
||||
return $this->updatedAt;
|
||||
$this->typeComposant = $typeComposant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
public function getTypePiece(): ?ModelType
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
return $this->typePiece;
|
||||
}
|
||||
|
||||
public function setTypePiece(?ModelType $typePiece): static
|
||||
{
|
||||
$this->typePiece = $typePiece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ?ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(?ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,17 +11,20 @@ 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;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: MachinePieceLinkRepository::class)]
|
||||
#[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 +36,8 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
)]
|
||||
class MachinePieceLink
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
@@ -64,6 +69,10 @@ class MachinePieceLink
|
||||
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true, name: 'prixOverride')]
|
||||
private ?string $prixOverride = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Assert\GreaterThanOrEqual(1)]
|
||||
private int $quantity = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
@@ -72,39 +81,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;
|
||||
@@ -177,8 +158,15 @@ class MachinePieceLink
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
public function getQuantity(): int
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function setQuantity(int $quantity): static
|
||||
{
|
||||
$this->quantity = $quantity;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +14,10 @@ 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 App\State\ModelTypeProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -30,12 +32,13 @@ 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')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ModelTypeProcessor::class),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ModelTypeProcessor::class),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ModelTypeProcessor::class),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
@@ -43,6 +46,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'])]
|
||||
@@ -68,18 +73,6 @@ class ModelType
|
||||
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'componentSkeleton')]
|
||||
#[Groups(['model_type:read', 'composant:read'])]
|
||||
private ?array $componentSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'pieceSkeleton')]
|
||||
#[Groups(['model_type:read', 'piece:read'])]
|
||||
private ?array $pieceSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'productSkeleton')]
|
||||
#[Groups(['model_type:read', 'product:read'])]
|
||||
private ?array $productSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['model_type:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -126,44 +119,40 @@ class ModelType
|
||||
#[ORM\OneToMany(mappedBy: 'typeProduct', targetEntity: CustomField::class)]
|
||||
private Collection $productCustomFields;
|
||||
|
||||
/**
|
||||
* @var Collection<int, SkeletonPieceRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: SkeletonPieceRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $skeletonPieceRequirements;
|
||||
|
||||
/**
|
||||
* @var Collection<int, SkeletonProductRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: SkeletonProductRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $skeletonProductRequirements;
|
||||
|
||||
/**
|
||||
* @var Collection<int, SkeletonSubcomponentRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: SkeletonSubcomponentRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $skeletonSubcomponentRequirements;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->composants = new ArrayCollection();
|
||||
$this->pieces = new ArrayCollection();
|
||||
$this->products = new ArrayCollection();
|
||||
$this->customFields = new ArrayCollection();
|
||||
$this->pieceCustomFields = new ArrayCollection();
|
||||
$this->productCustomFields = new ArrayCollection();
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->composants = new ArrayCollection();
|
||||
$this->pieces = new ArrayCollection();
|
||||
$this->products = new ArrayCollection();
|
||||
$this->customFields = new ArrayCollection();
|
||||
$this->pieceCustomFields = new ArrayCollection();
|
||||
$this->productCustomFields = new ArrayCollection();
|
||||
$this->skeletonPieceRequirements = new ArrayCollection();
|
||||
$this->skeletonProductRequirements = new ArrayCollection();
|
||||
$this->skeletonSubcomponentRequirements = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
@@ -199,11 +188,6 @@ class ModelType
|
||||
{
|
||||
$this->category = $category;
|
||||
|
||||
if (null !== $this->pendingStructure) {
|
||||
$this->applyStructureForCategory($this->pendingStructure, $category);
|
||||
$this->pendingStructure = null;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -231,66 +215,34 @@ class ModelType
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComponentSkeleton(): ?array
|
||||
{
|
||||
return $this->componentSkeleton;
|
||||
}
|
||||
|
||||
public function setComponentSkeleton(?array $componentSkeleton): static
|
||||
{
|
||||
$this->componentSkeleton = $componentSkeleton;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPieceSkeleton(): ?array
|
||||
{
|
||||
return $this->pieceSkeleton;
|
||||
}
|
||||
|
||||
public function setPieceSkeleton(?array $pieceSkeleton): static
|
||||
{
|
||||
$this->pieceSkeleton = $pieceSkeleton;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProductSkeleton(): ?array
|
||||
{
|
||||
return $this->productSkeleton;
|
||||
}
|
||||
|
||||
public function setProductSkeleton(?array $productSkeleton): static
|
||||
{
|
||||
$this->productSkeleton = $productSkeleton;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['model_type:read', 'product:read', 'composant:read', 'piece:read'])]
|
||||
public function getStructure(): ?array
|
||||
{
|
||||
return match ($this->category) {
|
||||
ModelCategory::COMPONENT => $this->componentSkeleton,
|
||||
ModelCategory::PIECE => $this->pieceSkeleton,
|
||||
ModelCategory::PRODUCT => $this->productSkeleton,
|
||||
ModelCategory::COMPONENT => $this->getComponentStructureFromRelations(),
|
||||
ModelCategory::PIECE => $this->getPieceStructureFromRelations(),
|
||||
ModelCategory::PRODUCT => ['customFields' => $this->serializeCustomFields($this->productCustomFields)],
|
||||
};
|
||||
}
|
||||
|
||||
#[Groups(['model_type:write'])]
|
||||
public function setStructure(?array $structure): static
|
||||
{
|
||||
if (!isset($this->category)) {
|
||||
$this->pendingStructure = $structure;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->applyStructureForCategory($structure, $this->category);
|
||||
$this->pendingStructure = $structure;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPendingStructure(): ?array
|
||||
{
|
||||
return $this->pendingStructure;
|
||||
}
|
||||
|
||||
public function clearPendingStructure(): void
|
||||
{
|
||||
$this->pendingStructure = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, CustomField>
|
||||
*/
|
||||
@@ -315,41 +267,140 @@ class ModelType
|
||||
return $this->productCustomFields;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
/**
|
||||
* @return Collection<int, SkeletonPieceRequirement>
|
||||
*/
|
||||
public function getSkeletonPieceRequirements(): Collection
|
||||
{
|
||||
return $this->createdAt;
|
||||
return $this->skeletonPieceRequirements;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
public function addSkeletonPieceRequirement(SkeletonPieceRequirement $requirement): static
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
|
||||
private function applyStructureForCategory(?array $structure, ModelCategory $category): void
|
||||
{
|
||||
if (ModelCategory::COMPONENT === $category) {
|
||||
$this->componentSkeleton = $structure;
|
||||
$this->pieceSkeleton = null;
|
||||
$this->productSkeleton = null;
|
||||
|
||||
return;
|
||||
if (!$this->skeletonPieceRequirements->contains($requirement)) {
|
||||
$this->skeletonPieceRequirements->add($requirement);
|
||||
$requirement->setModelType($this);
|
||||
}
|
||||
|
||||
if (ModelCategory::PIECE === $category) {
|
||||
$this->pieceSkeleton = $structure;
|
||||
$this->componentSkeleton = null;
|
||||
$this->productSkeleton = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
return;
|
||||
public function removeSkeletonPieceRequirement(SkeletonPieceRequirement $requirement): static
|
||||
{
|
||||
$this->skeletonPieceRequirements->removeElement($requirement);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SkeletonProductRequirement>
|
||||
*/
|
||||
public function getSkeletonProductRequirements(): Collection
|
||||
{
|
||||
return $this->skeletonProductRequirements;
|
||||
}
|
||||
|
||||
public function addSkeletonProductRequirement(SkeletonProductRequirement $requirement): static
|
||||
{
|
||||
if (!$this->skeletonProductRequirements->contains($requirement)) {
|
||||
$this->skeletonProductRequirements->add($requirement);
|
||||
$requirement->setModelType($this);
|
||||
}
|
||||
|
||||
$this->productSkeleton = $structure;
|
||||
$this->componentSkeleton = null;
|
||||
$this->pieceSkeleton = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSkeletonProductRequirement(SkeletonProductRequirement $requirement): static
|
||||
{
|
||||
$this->skeletonProductRequirements->removeElement($requirement);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SkeletonSubcomponentRequirement>
|
||||
*/
|
||||
public function getSkeletonSubcomponentRequirements(): Collection
|
||||
{
|
||||
return $this->skeletonSubcomponentRequirements;
|
||||
}
|
||||
|
||||
public function addSkeletonSubcomponentRequirement(SkeletonSubcomponentRequirement $requirement): static
|
||||
{
|
||||
if (!$this->skeletonSubcomponentRequirements->contains($requirement)) {
|
||||
$this->skeletonSubcomponentRequirements->add($requirement);
|
||||
$requirement->setModelType($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSkeletonSubcomponentRequirement(SkeletonSubcomponentRequirement $requirement): static
|
||||
{
|
||||
$this->skeletonSubcomponentRequirements->removeElement($requirement);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function getComponentStructureFromRelations(): array
|
||||
{
|
||||
$structure = ['customFields' => $this->serializeCustomFields($this->customFields), 'pieces' => [], 'products' => [], 'subcomponents' => []];
|
||||
|
||||
foreach ($this->skeletonPieceRequirements as $req) {
|
||||
$structure['pieces'][] = [
|
||||
'typePieceId' => $req->getTypePiece()->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->skeletonProductRequirements as $req) {
|
||||
$structure['products'][] = [
|
||||
'typeProductId' => $req->getTypeProduct()->getId(),
|
||||
'familyCode' => $req->getFamilyCode(),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->skeletonSubcomponentRequirements as $req) {
|
||||
$structure['subcomponents'][] = [
|
||||
'alias' => $req->getAlias(),
|
||||
'familyCode' => $req->getFamilyCode(),
|
||||
'typeComposantId' => $req->getTypeComposant()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
return $structure;
|
||||
}
|
||||
|
||||
private function getPieceStructureFromRelations(): array
|
||||
{
|
||||
return [
|
||||
'customFields' => $this->serializeCustomFields($this->pieceCustomFields),
|
||||
'products' => array_map(fn (SkeletonProductRequirement $req) => [
|
||||
'typeProductId' => $req->getTypeProduct()->getId(),
|
||||
'familyCode' => $req->getFamilyCode(),
|
||||
], $this->skeletonProductRequirements->toArray()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, CustomField> $fields
|
||||
*/
|
||||
private function serializeCustomFields(Collection $fields): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($fields as $cf) {
|
||||
$items[] = [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
|
||||
|
||||
return $items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'])]
|
||||
@@ -75,10 +79,6 @@ class Piece
|
||||
#[Groups(['piece:read'])]
|
||||
private ?Product $product = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'productIds')]
|
||||
#[Groups(['piece:read'])]
|
||||
private ?array $productIds = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Constructeur>
|
||||
*/
|
||||
@@ -105,12 +105,32 @@ class Piece
|
||||
#[Groups(['piece:read'])]
|
||||
private Collection $customFieldValues;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Product>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Product::class, inversedBy: 'linkedPieces')]
|
||||
#[ORM\JoinTable(name: 'piece_products')]
|
||||
#[ORM\JoinColumn(name: 'piece_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
private Collection $products;
|
||||
|
||||
/**
|
||||
* @var Collection<int, PieceProductSlot>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: PieceProductSlot::class, mappedBy: 'piece', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $productSlots;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachinePieceLink>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'piece', targetEntity: MachinePieceLink::class)]
|
||||
private Collection $machineLinks;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Groups(['piece:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['piece:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -121,42 +141,16 @@ 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->products = new ArrayCollection();
|
||||
$this->productSlots = 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;
|
||||
@@ -226,13 +220,8 @@ class Piece
|
||||
{
|
||||
$this->product = $product;
|
||||
|
||||
if ($product && empty($this->productIds)) {
|
||||
$productId = $product->getId();
|
||||
$this->productIds = $productId ? [$productId] : null;
|
||||
}
|
||||
|
||||
if (!$product && empty($this->productIds)) {
|
||||
$this->productIds = null;
|
||||
if (null !== $product) {
|
||||
$this->addProduct($product);
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -241,46 +230,10 @@ class Piece
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
#[Groups(['piece:read'])]
|
||||
public function getProductIds(): array
|
||||
{
|
||||
if (!is_array($this->productIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(
|
||||
array_filter(
|
||||
array_map(
|
||||
static fn ($value) => is_string($value) ? trim($value) : '',
|
||||
$this->productIds,
|
||||
),
|
||||
static fn (string $value) => '' !== $value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function setProductIds(?array $productIds): static
|
||||
{
|
||||
if (!is_array($productIds)) {
|
||||
$this->productIds = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$normalized = array_values(
|
||||
array_unique(
|
||||
array_filter(
|
||||
array_map(
|
||||
static fn ($value) => is_string($value) ? trim($value) : '',
|
||||
$productIds,
|
||||
),
|
||||
static fn (string $value) => '' !== $value,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
$this->productIds = [] === $normalized ? null : $normalized;
|
||||
|
||||
return $this;
|
||||
return $this->products->map(fn (Product $p) => $p->getId())->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -323,18 +276,64 @@ class Piece
|
||||
return $this->customFieldValues;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
/**
|
||||
* @return Collection<int, Product>
|
||||
*/
|
||||
public function getProducts(): Collection
|
||||
{
|
||||
return $this->createdAt;
|
||||
return $this->products;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
public function addProduct(Product $product): static
|
||||
{
|
||||
return $this->updatedAt;
|
||||
if (!$this->products->contains($product)) {
|
||||
$this->products->add($product);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
public function removeProduct(Product $product): static
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
$this->products->removeElement($product);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, PieceProductSlot>
|
||||
*/
|
||||
public function getProductSlots(): Collection
|
||||
{
|
||||
return $this->productSlots;
|
||||
}
|
||||
|
||||
public function addProductSlot(PieceProductSlot $slot): static
|
||||
{
|
||||
if (!$this->productSlots->contains($slot)) {
|
||||
$this->productSlots->add($slot);
|
||||
$slot->setPiece($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeProductSlot(PieceProductSlot $slot): static
|
||||
{
|
||||
$this->productSlots->removeElement($slot);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function incrementVersion(): static
|
||||
{
|
||||
++$this->version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
112
src/Entity/PieceProductSlot.php
Normal file
112
src/Entity/PieceProductSlot.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'piece_product_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class PieceProductSlot
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'productSlots')]
|
||||
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Piece $piece;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeProduct = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Product::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Product $selectedProduct = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getPiece(): Piece
|
||||
{
|
||||
return $this->piece;
|
||||
}
|
||||
|
||||
public function setPiece(Piece $piece): static
|
||||
{
|
||||
$this->piece = $piece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ?ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(?ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSelectedProduct(): ?Product
|
||||
{
|
||||
return $this->selectedProduct;
|
||||
}
|
||||
|
||||
public function setSelectedProduct(?Product $selectedProduct): static
|
||||
{
|
||||
$this->selectedProduct = $selectedProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): ?string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(?string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -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'])]
|
||||
@@ -102,12 +106,22 @@ class Product
|
||||
#[ORM\OneToMany(mappedBy: 'product', targetEntity: Composant::class)]
|
||||
private Collection $composants;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Piece>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Piece::class, mappedBy: 'products')]
|
||||
private Collection $linkedPieces;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachineProductLink>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'product', targetEntity: MachineProductLink::class)]
|
||||
private Collection $machineLinks;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Groups(['product:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['product:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -118,44 +132,17 @@ 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();
|
||||
$this->pieces = new ArrayCollection();
|
||||
$this->composants = new ArrayCollection();
|
||||
$this->linkedPieces = 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;
|
||||
@@ -244,18 +231,23 @@ class Product
|
||||
return $this->customFieldValues;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
/**
|
||||
* @return Collection<int, Piece>
|
||||
*/
|
||||
public function getLinkedPieces(): Collection
|
||||
{
|
||||
return $this->createdAt;
|
||||
return $this->linkedPieces;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->updatedAt;
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
public function incrementVersion(): static
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
++$this->version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
81
src/Entity/SkeletonPieceRequirement.php
Normal file
81
src/Entity/SkeletonPieceRequirement.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'skeleton_piece_requirements')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class SkeletonPieceRequirement
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'skeletonPieceRequirements')]
|
||||
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $modelType;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typePieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $typePiece;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getModelType(): ModelType
|
||||
{
|
||||
return $this->modelType;
|
||||
}
|
||||
|
||||
public function setModelType(ModelType $modelType): static
|
||||
{
|
||||
$this->modelType = $modelType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypePiece(): ModelType
|
||||
{
|
||||
return $this->typePiece;
|
||||
}
|
||||
|
||||
public function setTypePiece(ModelType $typePiece): static
|
||||
{
|
||||
$this->typePiece = $typePiece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
96
src/Entity/SkeletonProductRequirement.php
Normal file
96
src/Entity/SkeletonProductRequirement.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'skeleton_product_requirements')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class SkeletonProductRequirement
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'skeletonProductRequirements')]
|
||||
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $modelType;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $typeProduct;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getModelType(): ModelType
|
||||
{
|
||||
return $this->modelType;
|
||||
}
|
||||
|
||||
public function setModelType(ModelType $modelType): static
|
||||
{
|
||||
$this->modelType = $modelType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): ?string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(?string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
111
src/Entity/SkeletonSubcomponentRequirement.php
Normal file
111
src/Entity/SkeletonSubcomponentRequirement.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'skeleton_subcomponent_requirements')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class SkeletonSubcomponentRequirement
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'skeletonSubcomponentRequirements')]
|
||||
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $modelType;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
private string $alias;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, name: 'familyCode')]
|
||||
private string $familyCode;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeComposant = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getModelType(): ModelType
|
||||
{
|
||||
return $this->modelType;
|
||||
}
|
||||
|
||||
public function setModelType(ModelType $modelType): static
|
||||
{
|
||||
$this->modelType = $modelType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAlias(): string
|
||||
{
|
||||
return $this->alias;
|
||||
}
|
||||
|
||||
public function setAlias(string $alias): static
|
||||
{
|
||||
$this->alias = $alias;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeComposant(): ?ModelType
|
||||
{
|
||||
return $this->typeComposant;
|
||||
}
|
||||
|
||||
public function setTypeComposant(?ModelType $typeComposant): static
|
||||
{
|
||||
$this->typeComposant = $typeComposant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
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,44 @@ 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'),
|
||||
'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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
|
||||
/**
|
||||
* Keep the legacy single product relation in sync with the new productIds array.
|
||||
* Keep the legacy single product relation in sync with the ManyToMany products collection.
|
||||
*/
|
||||
final class PieceProductSyncSubscriber implements EventSubscriber
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -325,8 +325,6 @@ final class ModelTypeCategoryConversionService
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE model_types
|
||||
SET category = :cat,
|
||||
componentskeleton = pieceskeleton,
|
||||
pieceskeleton = NULL,
|
||||
updatedat = :now
|
||||
WHERE id = :id',
|
||||
[
|
||||
@@ -343,8 +341,8 @@ final class ModelTypeCategoryConversionService
|
||||
{
|
||||
// 1. Insert into pieces from composants
|
||||
$count = $this->connection->executeStatement(
|
||||
'INSERT INTO pieces (id, name, reference, prix, productids, typepieceid, productid, createdat, updatedat)
|
||||
SELECT id, name, reference, prix, NULL, typecomposantid, productid, createdat, updatedat
|
||||
'INSERT INTO pieces (id, name, reference, prix, typepieceid, productid, createdat, updatedat)
|
||||
SELECT id, name, reference, prix, typecomposantid, productid, createdat, updatedat
|
||||
FROM composants
|
||||
WHERE typecomposantid = :id',
|
||||
['id' => $modelTypeId],
|
||||
@@ -395,8 +393,6 @@ final class ModelTypeCategoryConversionService
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE model_types
|
||||
SET category = :cat,
|
||||
pieceskeleton = componentskeleton,
|
||||
componentskeleton = NULL,
|
||||
updatedat = :now
|
||||
WHERE id = :id',
|
||||
[
|
||||
|
||||
44
src/Service/ModelTypeSyncService.php
Normal file
44
src/Service/ModelTypeSyncService.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\DTO\SyncExecutionResult;
|
||||
use App\DTO\SyncPreviewResult;
|
||||
use App\Entity\ModelType;
|
||||
use App\Service\Sync\SyncStrategyInterface;
|
||||
use LogicException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
|
||||
|
||||
class ModelTypeSyncService
|
||||
{
|
||||
/** @param iterable<SyncStrategyInterface> $strategies */
|
||||
public function __construct(
|
||||
#[AutowireIterator('app.sync_strategy')]
|
||||
private readonly iterable $strategies,
|
||||
) {}
|
||||
|
||||
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult
|
||||
{
|
||||
foreach ($this->strategies as $strategy) {
|
||||
if ($strategy->supports($modelType)) {
|
||||
return $strategy->preview($modelType, $newStructure);
|
||||
}
|
||||
}
|
||||
|
||||
throw new LogicException('No sync strategy found for category: '.$modelType->getCategory()->value);
|
||||
}
|
||||
|
||||
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult
|
||||
{
|
||||
foreach ($this->strategies as $strategy) {
|
||||
if ($strategy->supports($modelType)) {
|
||||
return $strategy->execute($modelType, $confirmation);
|
||||
}
|
||||
}
|
||||
|
||||
throw new LogicException('No sync strategy found for category: '.$modelType->getCategory()->value);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
179
src/Service/SkeletonStructureService.php
Normal file
179
src/Service/SkeletonStructureService.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\SkeletonPieceRequirement;
|
||||
use App\Entity\SkeletonProductRequirement;
|
||||
use App\Entity\SkeletonSubcomponentRequirement;
|
||||
use App\Enum\ModelCategory;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class SkeletonStructureService
|
||||
{
|
||||
public function __construct(private EntityManagerInterface $em) {}
|
||||
|
||||
public function updateSkeletonRequirements(ModelType $modelType, array $structure): void
|
||||
{
|
||||
// Clear existing requirements
|
||||
foreach ($modelType->getSkeletonPieceRequirements() as $req) {
|
||||
$modelType->removeSkeletonPieceRequirement($req);
|
||||
}
|
||||
|
||||
foreach ($modelType->getSkeletonProductRequirements() as $req) {
|
||||
$modelType->removeSkeletonProductRequirement($req);
|
||||
}
|
||||
|
||||
foreach ($modelType->getSkeletonSubcomponentRequirements() as $req) {
|
||||
$modelType->removeSkeletonSubcomponentRequirement($req);
|
||||
}
|
||||
|
||||
// Create piece requirements
|
||||
foreach (($structure['pieces'] ?? []) as $i => $pieceData) {
|
||||
$req = new SkeletonPieceRequirement();
|
||||
$req->setModelType($modelType);
|
||||
$req->setTypePiece($this->em->getReference(ModelType::class, $pieceData['typePieceId']));
|
||||
$req->setPosition($i);
|
||||
$modelType->addSkeletonPieceRequirement($req);
|
||||
}
|
||||
|
||||
// Create product requirements (shared by component + piece types)
|
||||
foreach (($structure['products'] ?? []) as $i => $prodData) {
|
||||
$req = new SkeletonProductRequirement();
|
||||
$req->setModelType($modelType);
|
||||
$req->setTypeProduct($this->em->getReference(ModelType::class, $prodData['typeProductId']));
|
||||
$req->setFamilyCode($prodData['familyCode'] ?? null);
|
||||
$req->setPosition($i);
|
||||
$modelType->addSkeletonProductRequirement($req);
|
||||
}
|
||||
|
||||
// Create subcomponent requirements (component types only)
|
||||
foreach (($structure['subcomponents'] ?? []) as $i => $subData) {
|
||||
$req = new SkeletonSubcomponentRequirement();
|
||||
$req->setModelType($modelType);
|
||||
$req->setAlias($subData['alias'] ?? '');
|
||||
$req->setFamilyCode($subData['familyCode'] ?? '');
|
||||
if (!empty($subData['typeComposantId'])) {
|
||||
$req->setTypeComposant($this->em->getReference(ModelType::class, $subData['typeComposantId']));
|
||||
}
|
||||
$req->setPosition($i);
|
||||
$modelType->addSkeletonSubcomponentRequirement($req);
|
||||
}
|
||||
|
||||
// Update custom field definitions
|
||||
$this->updateCustomFields($modelType, $structure['customFields'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync CustomField entities for this ModelType.
|
||||
* Handles two frontend formats:
|
||||
* - COMPONENT: {key, value: {type, required, options?, defaultValue?}, id?, customFieldId?}
|
||||
* - PIECE/PRODUCT: {name, type, required, options?, orderIndex?, defaultValue?}.
|
||||
*/
|
||||
private function updateCustomFields(ModelType $modelType, array $proposedFields): void
|
||||
{
|
||||
// Determine which FK to use based on category
|
||||
$category = $modelType->getCategory();
|
||||
$fkField = match ($category) {
|
||||
ModelCategory::COMPONENT => 'typeComposant',
|
||||
ModelCategory::PIECE => 'typePiece',
|
||||
ModelCategory::PRODUCT => 'typeProduct',
|
||||
};
|
||||
|
||||
// Load existing custom fields
|
||||
$existingFields = $this->em->getRepository(CustomField::class)->findBy(
|
||||
[$fkField => $modelType],
|
||||
['orderIndex' => 'ASC']
|
||||
);
|
||||
|
||||
// Index existing by ID for matching
|
||||
$existingById = [];
|
||||
foreach ($existingFields as $cf) {
|
||||
$existingById[$cf->getId()] = $cf;
|
||||
}
|
||||
|
||||
$processedIds = [];
|
||||
|
||||
foreach ($proposedFields as $i => $fieldData) {
|
||||
// Normalize both formats to a common shape
|
||||
$normalized = $this->normalizeCustomFieldData($fieldData, $i);
|
||||
|
||||
// Try to match an existing field by ID
|
||||
$existingField = null;
|
||||
$fieldId = $fieldData['customFieldId'] ?? $fieldData['id'] ?? null;
|
||||
if ($fieldId && isset($existingById[$fieldId])) {
|
||||
$existingField = $existingById[$fieldId];
|
||||
}
|
||||
|
||||
if ($existingField) {
|
||||
// Update existing field
|
||||
$existingField->setName($normalized['name']);
|
||||
$existingField->setType($normalized['type']);
|
||||
$existingField->setRequired($normalized['required']);
|
||||
$existingField->setOptions($normalized['options']);
|
||||
$existingField->setDefaultValue($normalized['defaultValue']);
|
||||
$existingField->setOrderIndex($normalized['orderIndex']);
|
||||
$processedIds[$existingField->getId()] = true;
|
||||
} else {
|
||||
// Create new field
|
||||
$cf = new CustomField();
|
||||
$cf->setName($normalized['name']);
|
||||
$cf->setType($normalized['type']);
|
||||
$cf->setRequired($normalized['required']);
|
||||
$cf->setOptions($normalized['options']);
|
||||
$cf->setDefaultValue($normalized['defaultValue']);
|
||||
$cf->setOrderIndex($normalized['orderIndex']);
|
||||
|
||||
match ($category) {
|
||||
ModelCategory::COMPONENT => $cf->setTypeComposant($modelType),
|
||||
ModelCategory::PIECE => $cf->setTypePiece($modelType),
|
||||
ModelCategory::PRODUCT => $cf->setTypeProduct($modelType),
|
||||
};
|
||||
|
||||
$this->em->persist($cf);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove orphaned fields (exist in DB but not in proposed)
|
||||
foreach ($existingFields as $cf) {
|
||||
if (!isset($processedIds[$cf->getId()])) {
|
||||
$this->em->remove($cf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize frontend custom field data to a common shape.
|
||||
*
|
||||
* @return array{name: string, type: string, required: bool, options: ?array, defaultValue: ?string, orderIndex: int}
|
||||
*/
|
||||
private function normalizeCustomFieldData(array $fieldData, int $index): array
|
||||
{
|
||||
// COMPONENT format: {key: "name", value: {type, required, options?, defaultValue?}}
|
||||
if (isset($fieldData['key'], $fieldData['value'])) {
|
||||
$value = $fieldData['value'];
|
||||
|
||||
return [
|
||||
'name' => $fieldData['key'],
|
||||
'type' => $value['type'] ?? 'text',
|
||||
'required' => (bool) ($value['required'] ?? false),
|
||||
'options' => $value['options'] ?? null,
|
||||
'defaultValue' => $value['defaultValue'] ?? null,
|
||||
'orderIndex' => $index,
|
||||
];
|
||||
}
|
||||
|
||||
// PIECE/PRODUCT format: {name, type, required, options?, orderIndex?, defaultValue?}
|
||||
return [
|
||||
'name' => $fieldData['name'] ?? '',
|
||||
'type' => $fieldData['type'] ?? 'text',
|
||||
'required' => (bool) ($fieldData['required'] ?? false),
|
||||
'options' => $fieldData['options'] ?? null,
|
||||
'defaultValue' => $fieldData['defaultValue'] ?? null,
|
||||
'orderIndex' => $fieldData['orderIndex'] ?? $index,
|
||||
];
|
||||
}
|
||||
}
|
||||
392
src/Service/Sync/ComposantSyncStrategy.php
Normal file
392
src/Service/Sync/ComposantSyncStrategy.php
Normal file
@@ -0,0 +1,392 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Sync;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\DTO\SyncExecutionResult;
|
||||
use App\DTO\SyncPreviewResult;
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\ComposantPieceSlot;
|
||||
use App\Entity\ComposantProductSlot;
|
||||
use App\Entity\ComposantSubcomponentSlot;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\SkeletonPieceRequirement;
|
||||
use App\Entity\SkeletonProductRequirement;
|
||||
use App\Entity\SkeletonSubcomponentRequirement;
|
||||
use App\Enum\ModelCategory;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
#[AutoconfigureTag('app.sync_strategy')]
|
||||
class ComposantSyncStrategy implements SyncStrategyInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function supports(ModelType $modelType): bool
|
||||
{
|
||||
return ModelCategory::COMPONENT === $modelType->getCategory();
|
||||
}
|
||||
|
||||
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult
|
||||
{
|
||||
$composants = $this->em->getRepository(Composant::class)->findBy(['typeComposant' => $modelType]);
|
||||
|
||||
$proposedPieces = $newStructure['pieces'] ?? [];
|
||||
$proposedProducts = $newStructure['products'] ?? [];
|
||||
$proposedSubcomponents = $newStructure['subcomponents'] ?? [];
|
||||
$proposedCustomFields = $newStructure['customFields'] ?? [];
|
||||
|
||||
$addedPieceSlots = 0;
|
||||
$deletedPieceSlots = 0;
|
||||
$addedProductSlots = 0;
|
||||
$deletedProductSlots = 0;
|
||||
$addedSubSlots = 0;
|
||||
$deletedSubSlots = 0;
|
||||
$addedCfValues = 0;
|
||||
$deletedCfValues = 0;
|
||||
|
||||
// Map proposed by (typeId, position) keys — position defaults to array index
|
||||
$proposedPieceKeys = [];
|
||||
foreach ($proposedPieces as $i => $pp) {
|
||||
$pos = $pp['position'] ?? $i;
|
||||
$proposedPieceKeys[$pp['typePieceId'].'|'.$pos] = true;
|
||||
}
|
||||
|
||||
$proposedProductKeys = [];
|
||||
foreach ($proposedProducts as $i => $pp) {
|
||||
$pos = $pp['position'] ?? $i;
|
||||
$proposedProductKeys[$pp['typeProductId'].'|'.$pos] = true;
|
||||
}
|
||||
|
||||
$proposedSubKeys = [];
|
||||
foreach ($proposedSubcomponents as $i => $ps) {
|
||||
$pos = $ps['position'] ?? $i;
|
||||
$proposedSubKeys[$ps['typeComposantId'].'|'.$pos] = true;
|
||||
}
|
||||
|
||||
// Map proposed custom fields by orderIndex (falls back to array index)
|
||||
$proposedCfByOrder = [];
|
||||
foreach ($proposedCustomFields as $i => $pcf) {
|
||||
$order = $pcf['orderIndex'] ?? $i;
|
||||
$proposedCfByOrder[$order] = $pcf;
|
||||
}
|
||||
|
||||
// Get existing custom fields for this model type
|
||||
$existingFields = $this->em->getRepository(CustomField::class)->findBy(
|
||||
['typeComposant' => $modelType],
|
||||
['orderIndex' => 'ASC']
|
||||
);
|
||||
$existingCfByOrder = [];
|
||||
foreach ($existingFields as $field) {
|
||||
$existingCfByOrder[$field->getOrderIndex()] = $field;
|
||||
}
|
||||
|
||||
// Count custom field additions/deletions (definition-level, affects all composants)
|
||||
$cfAdded = 0;
|
||||
$cfDeleted = 0;
|
||||
foreach ($proposedCfByOrder as $orderIndex => $pcf) {
|
||||
if (!isset($existingCfByOrder[$orderIndex])) {
|
||||
++$cfAdded;
|
||||
}
|
||||
}
|
||||
foreach ($existingCfByOrder as $orderIndex => $ef) {
|
||||
if (!isset($proposedCfByOrder[$orderIndex])) {
|
||||
++$cfDeleted;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($composants as $composant) {
|
||||
// Piece slots — query from repository to avoid stale collection
|
||||
$pieceSlots = $this->em->getRepository(ComposantPieceSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingPieceKeys = [];
|
||||
foreach ($pieceSlots as $slot) {
|
||||
$key = ($slot->getTypePiece()?->getId() ?? '').'|'.$slot->getPosition();
|
||||
$existingPieceKeys[$key] = true;
|
||||
}
|
||||
foreach ($proposedPieceKeys as $key => $_) {
|
||||
if (!isset($existingPieceKeys[$key])) {
|
||||
++$addedPieceSlots;
|
||||
}
|
||||
}
|
||||
foreach ($existingPieceKeys as $key => $_) {
|
||||
if (!isset($proposedPieceKeys[$key])) {
|
||||
++$deletedPieceSlots;
|
||||
}
|
||||
}
|
||||
|
||||
// Product slots
|
||||
$productSlots = $this->em->getRepository(ComposantProductSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingProductKeys = [];
|
||||
foreach ($productSlots as $slot) {
|
||||
$key = ($slot->getTypeProduct()?->getId() ?? '').'|'.$slot->getPosition();
|
||||
$existingProductKeys[$key] = true;
|
||||
}
|
||||
foreach ($proposedProductKeys as $key => $_) {
|
||||
if (!isset($existingProductKeys[$key])) {
|
||||
++$addedProductSlots;
|
||||
}
|
||||
}
|
||||
foreach ($existingProductKeys as $key => $_) {
|
||||
if (!isset($proposedProductKeys[$key])) {
|
||||
++$deletedProductSlots;
|
||||
}
|
||||
}
|
||||
|
||||
// Subcomponent slots
|
||||
$subSlots = $this->em->getRepository(ComposantSubcomponentSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingSubKeys = [];
|
||||
foreach ($subSlots as $slot) {
|
||||
$key = ($slot->getTypeComposant()?->getId() ?? '').'|'.$slot->getPosition();
|
||||
$existingSubKeys[$key] = true;
|
||||
}
|
||||
foreach ($proposedSubKeys as $key => $_) {
|
||||
if (!isset($existingSubKeys[$key])) {
|
||||
++$addedSubSlots;
|
||||
}
|
||||
}
|
||||
foreach ($existingSubKeys as $key => $_) {
|
||||
if (!isset($proposedSubKeys[$key])) {
|
||||
++$deletedSubSlots;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom field values
|
||||
$addedCfValues += $cfAdded;
|
||||
$deletedCfValues += $cfDeleted;
|
||||
}
|
||||
|
||||
$itemCount = count($composants);
|
||||
|
||||
return new SyncPreviewResult(
|
||||
modelTypeId: $modelType->getId(),
|
||||
category: 'component',
|
||||
itemCount: $itemCount,
|
||||
additions: [
|
||||
'pieceSlots' => $addedPieceSlots,
|
||||
'productSlots' => $addedProductSlots,
|
||||
'subcomponentSlots' => $addedSubSlots,
|
||||
'customFieldValues' => $addedCfValues,
|
||||
],
|
||||
deletions: [
|
||||
'pieceSlots' => $deletedPieceSlots,
|
||||
'productSlots' => $deletedProductSlots,
|
||||
'subcomponentSlots' => $deletedSubSlots,
|
||||
'customFieldValues' => $deletedCfValues,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult
|
||||
{
|
||||
$composants = $this->em->getRepository(Composant::class)->findBy(['typeComposant' => $modelType]);
|
||||
|
||||
// Load skeleton requirements
|
||||
$pieceReqs = $this->em->getRepository(SkeletonPieceRequirement::class)->findBy(['modelType' => $modelType]);
|
||||
$productReqs = $this->em->getRepository(SkeletonProductRequirement::class)->findBy(['modelType' => $modelType]);
|
||||
$subReqs = $this->em->getRepository(SkeletonSubcomponentRequirement::class)->findBy(['modelType' => $modelType]);
|
||||
$customFields = $this->em->getRepository(CustomField::class)->findBy(
|
||||
['typeComposant' => $modelType],
|
||||
['orderIndex' => 'ASC']
|
||||
);
|
||||
|
||||
// Map requirements by (typeId, position)
|
||||
$pieceReqKeys = [];
|
||||
foreach ($pieceReqs as $req) {
|
||||
$pieceReqKeys[$req->getTypePiece()->getId().'|'.$req->getPosition()] = $req;
|
||||
}
|
||||
|
||||
$productReqKeys = [];
|
||||
foreach ($productReqs as $req) {
|
||||
$productReqKeys[$req->getTypeProduct()->getId().'|'.$req->getPosition()] = $req;
|
||||
}
|
||||
|
||||
$subReqKeys = [];
|
||||
foreach ($subReqs as $req) {
|
||||
$key = ($req->getTypeComposant()?->getId() ?? '').'|'.$req->getPosition();
|
||||
$subReqKeys[$key] = $req;
|
||||
}
|
||||
|
||||
$addedPieceSlots = 0;
|
||||
$deletedPieceSlots = 0;
|
||||
$addedProductSlots = 0;
|
||||
$deletedProductSlots = 0;
|
||||
$addedSubSlots = 0;
|
||||
$deletedSubSlots = 0;
|
||||
$addedCfValues = 0;
|
||||
$deletedCfValues = 0;
|
||||
$itemsUpdated = 0;
|
||||
|
||||
foreach ($composants as $composant) {
|
||||
$changed = false;
|
||||
|
||||
// --- Piece slots — query from repository to avoid stale collection ---
|
||||
$pieceSlotEntities = $this->em->getRepository(ComposantPieceSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingPieceSlots = [];
|
||||
foreach ($pieceSlotEntities as $slot) {
|
||||
$key = ($slot->getTypePiece()?->getId() ?? '').'|'.$slot->getPosition();
|
||||
$existingPieceSlots[$key] = $slot;
|
||||
}
|
||||
|
||||
// Add missing piece slots
|
||||
foreach ($pieceReqKeys as $key => $req) {
|
||||
if (!isset($existingPieceSlots[$key])) {
|
||||
$slot = new ComposantPieceSlot();
|
||||
$slot->setComposant($composant);
|
||||
$slot->setTypePiece($req->getTypePiece());
|
||||
$slot->setPosition($req->getPosition());
|
||||
// Default quantity = 1, selectedPiece = null (already defaults)
|
||||
$this->em->persist($slot);
|
||||
++$addedPieceSlots;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete orphaned piece slots
|
||||
if ($confirmation->confirmDeletions) {
|
||||
foreach ($existingPieceSlots as $key => $slot) {
|
||||
if (!isset($pieceReqKeys[$key])) {
|
||||
$composant->removePieceSlot($slot);
|
||||
$this->em->remove($slot);
|
||||
++$deletedPieceSlots;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Product slots ---
|
||||
$productSlotEntities = $this->em->getRepository(ComposantProductSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingProductSlots = [];
|
||||
foreach ($productSlotEntities as $slot) {
|
||||
$key = ($slot->getTypeProduct()?->getId() ?? '').'|'.$slot->getPosition();
|
||||
$existingProductSlots[$key] = $slot;
|
||||
}
|
||||
|
||||
// Add missing product slots
|
||||
foreach ($productReqKeys as $key => $req) {
|
||||
if (!isset($existingProductSlots[$key])) {
|
||||
$slot = new ComposantProductSlot();
|
||||
$slot->setComposant($composant);
|
||||
$slot->setTypeProduct($req->getTypeProduct());
|
||||
$slot->setPosition($req->getPosition());
|
||||
if (null !== $req->getFamilyCode()) {
|
||||
$slot->setFamilyCode($req->getFamilyCode());
|
||||
}
|
||||
$this->em->persist($slot);
|
||||
++$addedProductSlots;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete orphaned product slots
|
||||
if ($confirmation->confirmDeletions) {
|
||||
foreach ($existingProductSlots as $key => $slot) {
|
||||
if (!isset($productReqKeys[$key])) {
|
||||
$composant->removeProductSlot($slot);
|
||||
$this->em->remove($slot);
|
||||
++$deletedProductSlots;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Subcomponent slots ---
|
||||
$subSlotEntities = $this->em->getRepository(ComposantSubcomponentSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingSubSlots = [];
|
||||
foreach ($subSlotEntities as $slot) {
|
||||
$key = ($slot->getTypeComposant()?->getId() ?? '').'|'.$slot->getPosition();
|
||||
$existingSubSlots[$key] = $slot;
|
||||
}
|
||||
|
||||
// Add missing subcomponent slots
|
||||
foreach ($subReqKeys as $key => $req) {
|
||||
if (!isset($existingSubSlots[$key])) {
|
||||
$slot = new ComposantSubcomponentSlot();
|
||||
$slot->setComposant($composant);
|
||||
$slot->setTypeComposant($req->getTypeComposant());
|
||||
$slot->setPosition($req->getPosition());
|
||||
$slot->setAlias($req->getAlias());
|
||||
$slot->setFamilyCode($req->getFamilyCode());
|
||||
$this->em->persist($slot);
|
||||
++$addedSubSlots;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete orphaned subcomponent slots
|
||||
if ($confirmation->confirmDeletions) {
|
||||
foreach ($existingSubSlots as $key => $slot) {
|
||||
if (!isset($subReqKeys[$key])) {
|
||||
$composant->removeSubcomponentSlot($slot);
|
||||
$this->em->remove($slot);
|
||||
++$deletedSubSlots;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Custom field values ---
|
||||
$existingValues = $this->em->getRepository(CustomFieldValue::class)->findBy([
|
||||
'composant' => $composant,
|
||||
]);
|
||||
|
||||
$existingByFieldId = [];
|
||||
foreach ($existingValues as $cfv) {
|
||||
$existingByFieldId[$cfv->getCustomField()->getId()] = $cfv;
|
||||
}
|
||||
|
||||
// Add missing custom field values
|
||||
foreach ($customFields as $cf) {
|
||||
if (!isset($existingByFieldId[$cf->getId()])) {
|
||||
$cfv = new CustomFieldValue();
|
||||
$cfv->setCustomField($cf);
|
||||
$cfv->setComposant($composant);
|
||||
$cfv->setValue('');
|
||||
$this->em->persist($cfv);
|
||||
++$addedCfValues;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete orphaned custom field values
|
||||
if ($confirmation->confirmDeletions) {
|
||||
$fieldIds = array_map(fn (CustomField $cf) => $cf->getId(), $customFields);
|
||||
foreach ($existingValues as $cfv) {
|
||||
if (!in_array($cfv->getCustomField()->getId(), $fieldIds, true)) {
|
||||
$this->em->remove($cfv);
|
||||
++$deletedCfValues;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$composant->incrementVersion();
|
||||
++$itemsUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return new SyncExecutionResult(
|
||||
itemsUpdated: $itemsUpdated,
|
||||
additions: [
|
||||
'pieceSlots' => $addedPieceSlots,
|
||||
'productSlots' => $addedProductSlots,
|
||||
'subcomponentSlots' => $addedSubSlots,
|
||||
'customFieldValues' => $addedCfValues,
|
||||
],
|
||||
deletions: [
|
||||
'pieceSlots' => $deletedPieceSlots,
|
||||
'productSlots' => $deletedProductSlots,
|
||||
'subcomponentSlots' => $deletedSubSlots,
|
||||
'customFieldValues' => $deletedCfValues,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user