Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e0acf4896 | ||
|
|
aa8e043c83 | ||
|
|
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 | ||
|
|
ea2b813728 | ||
|
|
20653b9046 | ||
|
|
c6deef6028 | ||
|
|
e922b14419 | ||
|
|
d16b042739 | ||
|
|
2b3c1fe08e | ||
|
|
51248b7854 | ||
|
|
0e11f4ad2d | ||
|
|
f2539099bc | ||
|
|
e5dc60467e | ||
|
|
fbc0372bd6 | ||
|
|
1483b0075b | ||
|
|
74e88923dc | ||
|
|
ef61d1a0d3 | ||
|
|
3f0fb0d5c2 | ||
|
|
dd1497beac | ||
|
|
7cd8772617 | ||
|
|
d89c97f0a0 | ||
|
|
7a5dd0b555 | ||
|
|
44d69db560 | ||
|
|
453065c9f0 | ||
|
|
eb85323116 | ||
|
|
2dfa501a65 | ||
|
|
c22f9dbf2b | ||
|
|
27a1b09d62 | ||
|
|
7bbb693924 | ||
|
|
9661fd5d91 | ||
|
|
d9ab583879 | ||
|
|
5d41bda997 | ||
|
|
3d037083c6 | ||
|
|
a3e440c254 | ||
|
|
adc44b99d3 | ||
|
|
60afeb4cfd |
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 ###
|
||||
|
||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -32,9 +32,20 @@ docker/.env.docker.local
|
||||
/_archives/
|
||||
###< migration archives ###
|
||||
|
||||
###> temp files ###
|
||||
*.sql
|
||||
*.har
|
||||
FEATURE_IDEAS.md
|
||||
###< temp files ###
|
||||
|
||||
###> frontend ###
|
||||
/frontend/node_modules/
|
||||
/frontend/.nuxt/
|
||||
/frontend/.output/
|
||||
/frontend/dist/
|
||||
/frontend/
|
||||
###< frontend ###
|
||||
|
||||
###> ide ###
|
||||
/.idea/
|
||||
###< ide ###
|
||||
|
||||
###> wsl ###
|
||||
*:Zone.Identifier
|
||||
###< wsl ###
|
||||
|
||||
9
.idea/.gitignore
generated
vendored
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>
|
||||
@@ -1,425 +0,0 @@
|
||||
# 📔 Carnet de Bord - Migration Inventory → Symfony
|
||||
|
||||
**Projet** : Migration backend NestJS/Prisma → Symfony/API Platform
|
||||
**Début** : 2026-01-10
|
||||
**Objectif** : Migrer vers Symfony + JWT + API Platform propre et maintenable
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Convention de liaison des commits (INV)
|
||||
|
||||
- **Format** : `[INV-YYYYMMDD-XX]`
|
||||
- **Usage** : même code dans les commits du backend **et** du frontend + ajout ici pour retrouver le duo rapidement.
|
||||
|
||||
## 🧾 Journal des liaisons INV
|
||||
|
||||
- INV-20260111-01 : ajout du lien submodule `Inventory_frontend` (commit backend : `987aa5c`, commit frontend : `936a73f`)
|
||||
- INV-20260111-02 : alignement front API Platform + sessions (commit backend : `f7fc1bd`, commit frontend : `e99f053`)
|
||||
|
||||
## 🎯 Contexte
|
||||
|
||||
- **Situation initiale** :
|
||||
- `Inventory_backend/` : NestJS + Prisma (fonctionnel, ~11k lignes)
|
||||
- `Inventory_frontend/` : Nuxt 3 (fonctionnel, 105 fichiers)
|
||||
- Base de données PostgreSQL avec données en production
|
||||
|
||||
- **Objectif** :
|
||||
- Backend Symfony 8 + API Platform + JWT
|
||||
- Garder les données existantes (migration Prisma → Doctrine)
|
||||
- Frontend Nuxt connecté au nouveau backend
|
||||
- Docker : 2 backends en parallèle pendant transition
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 1 : Préparation (TERMINÉE - 10/01/2026)
|
||||
|
||||
### Ce qui a été fait
|
||||
|
||||
#### 1. Docker & Infrastructure ✅
|
||||
- **pgAdmin ajouté** au docker-compose.yml
|
||||
- Port : 5050
|
||||
- Login : admin@admin.com / admin
|
||||
- Container : `pgadmin-inventory`
|
||||
- Volume persistant : `pgadmin_data`
|
||||
- **Serveur PostgreSQL pré-configuré** :
|
||||
- Fichier `docker/pgadmin/servers.json` monté automatiquement
|
||||
- Fichier `docker/pgadmin/pgpass` pour authentification sans mot de passe
|
||||
- Connexion automatique à `db:5432/inventory` au démarrage
|
||||
- Nom du serveur : "Inventory PostgreSQL"
|
||||
|
||||
#### 2. Bundles Symfony installés ✅
|
||||
```bash
|
||||
# Versions installées
|
||||
- lexik/jwt-authentication-bundle: v3.2.0
|
||||
- vich/uploader-bundle: v2.9.1
|
||||
- symfony/uid: 8.0.*
|
||||
```
|
||||
|
||||
#### 3. JWT Configuration ✅
|
||||
- **Clés RSA générées** : `config/jwt/private.pem` + `public.pem`
|
||||
- **security.yaml configuré** :
|
||||
- Firewall `login` : pattern `^/api/login_check` avec `json_login`
|
||||
- Firewall `api` : pattern `^/api` avec `jwt` authenticator
|
||||
- Provider : `app_user_provider` (entité Profile via email)
|
||||
- Password hasher : bcrypt auto
|
||||
|
||||
#### 4. Entité Profile créée ✅
|
||||
**Fichier** : `src/Entity/Profile.php`
|
||||
|
||||
**Caractéristiques** :
|
||||
- Implémente `UserInterface` + `PasswordAuthenticatedUserInterface`
|
||||
- Champs :
|
||||
- `id` : string (30 chars, CUID-compatible pour Prisma)
|
||||
- `email` : string unique (username pour JWT)
|
||||
- `password` : string (hashed)
|
||||
- `roles` : array JSON (ROLE_USER par défaut)
|
||||
- `firstName`, `lastName` : string
|
||||
- `isActive` : boolean
|
||||
- `createdAt`, `updatedAt` : DateTimeImmutable
|
||||
- Repository : `ProfileRepository` avec `PasswordUpgraderInterface`
|
||||
- API Platform : endpoints CRUD auto-générés
|
||||
|
||||
#### 5. Base de Données ✅
|
||||
- **Migration créée** : `Version20260110175413`
|
||||
- **Table** : `profiles` créée avec succès
|
||||
- **Utilisateur test créé** :
|
||||
```
|
||||
Email: admin@admin.com
|
||||
Password: admin123
|
||||
Roles: ['ROLE_USER', 'ROLE_ADMIN']
|
||||
```
|
||||
|
||||
#### 6. API Platform ✅
|
||||
- **Endpoint racine** : http://localhost:8081/api/
|
||||
- **Réponse** :
|
||||
```json
|
||||
{
|
||||
"@context": "/api/contexts/Entrypoint",
|
||||
"@id": "/api/",
|
||||
"@type": "Entrypoint",
|
||||
"profile": "/api/profiles"
|
||||
}
|
||||
```
|
||||
- **OpenAPI Docs** : Configurées (à tester)
|
||||
|
||||
#### 7. Configuration Apache ✅
|
||||
- **VirtualHost** : `docker/php/config/vhost.conf`
|
||||
- **DocumentRoot** : `/var/www/html/public`
|
||||
- **AllowOverride** : All (pour `.htaccess`)
|
||||
- **Port** : 8081 (Apache) → accessible depuis l'hôte
|
||||
|
||||
#### 8. Routing Symfony ✅
|
||||
- **Routes définies** :
|
||||
- `/api/login_check` : Login JWT
|
||||
- `/api/test` : Test endpoint (TestController)
|
||||
- `/api/*` : API Platform auto-routes
|
||||
- **Vérification** :
|
||||
```bash
|
||||
php bin/console debug:router api_test
|
||||
php bin/console router:match /api/test --method=GET
|
||||
# ✅ Route found and matches
|
||||
```
|
||||
|
||||
#### 9. .htaccess créé ✅
|
||||
**Fichier** : `public/.htaccess`
|
||||
|
||||
**Contenu** : Symfony standard avec mod_rewrite
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ Problèmes identifiés
|
||||
|
||||
#### 1. Routes inaccessibles via Apache (404)
|
||||
|
||||
**Symptôme** :
|
||||
```bash
|
||||
curl http://localhost:8081/api/test
|
||||
# → 404 Not Found
|
||||
```
|
||||
|
||||
**Tests effectués** :
|
||||
- ✅ Route existe : `php bin/console debug:router api_test`
|
||||
- ✅ Route match : `php bin/console router:match /api/test`
|
||||
- ✅ Symfony fonctionne : `curl http://localhost:8081/api/` → JSON OK
|
||||
- ✅ PHP built-in server OK :
|
||||
```bash
|
||||
php -S localhost:9000
|
||||
curl http://localhost:9000/api/test
|
||||
# → {"status":"ok","message":"Test endpoint works!"}
|
||||
```
|
||||
- ❌ Apache 404 : Depuis l'hôte via port 8081
|
||||
|
||||
**Diagnostic** :
|
||||
- Le problème est **Apache-spécifique**
|
||||
- Symfony/PHP fonctionnent correctement
|
||||
- Le `.htaccess` n'est probablement **PAS lu par Apache**
|
||||
- Hypothèses :
|
||||
1. `AllowOverride All` non pris en compte
|
||||
2. `mod_rewrite` mal configuré
|
||||
3. Ordre des directives Apache incorrect
|
||||
4. Problème de permissions sur `.htaccess`
|
||||
|
||||
**Actions à faire** :
|
||||
- [ ] Vérifier permissions `.htaccess` dans container
|
||||
- [ ] Tester `apache2ctl configtest`
|
||||
- [ ] Activer logs de rewrite : `LogLevel alert rewrite:trace3`
|
||||
- [ ] Tester FallbackResource dans vhost au lieu de `.htaccess`
|
||||
|
||||
#### 2. JWT Login non testé
|
||||
|
||||
**Raison** : Bloqué par problème #1 (routes inaccessibles)
|
||||
|
||||
**Actions à faire** :
|
||||
- [ ] Résoudre problème Apache
|
||||
- [ ] Tester `POST /api/login_check` avec credentials
|
||||
- [ ] Vérifier génération du token JWT
|
||||
- [ ] Tester route protégée avec token
|
||||
|
||||
---
|
||||
|
||||
## 📝 Configuration Actuelle
|
||||
|
||||
### Docker Compose
|
||||
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
ports:
|
||||
- "8081:80" # Symfony API
|
||||
- "3001:3000" # (prévu pour Nuxt)
|
||||
|
||||
db:
|
||||
ports:
|
||||
- "5433:5432" # PostgreSQL
|
||||
|
||||
pgadmin:
|
||||
ports:
|
||||
- "5050:80" # pgAdmin
|
||||
```
|
||||
|
||||
### Variables d'Environnement
|
||||
|
||||
**Fichier** : `docker/.env.docker.local`
|
||||
|
||||
```env
|
||||
# PostgreSQL
|
||||
POSTGRES_DB=inventory
|
||||
POSTGRES_USER=root
|
||||
POSTGRES_PASSWORD=root
|
||||
POSTGRES_PORT=5433
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 2 : Migration DB + Frontend (TERMINÉE - 10/01/2026)
|
||||
|
||||
### Ce qui a été fait
|
||||
|
||||
#### 1. Entités Doctrine alignées Prisma ✅
|
||||
- **Toutes les entités manquantes** créées (Machine, ModelType, Composant, Piece, Product, Links, Requirements, CustomField, Document, etc.)
|
||||
- **IDs en string(36)** pour compatibilité CUID/UUID
|
||||
- **Colonnes Prisma en camelCase** conservées via `name="..."` (ex: `machineId`, `createdAt`, `supplierPrice`)
|
||||
- **Corrections** :
|
||||
- `Document.path` passé en `TEXT`
|
||||
- `CustomField.options` nullable
|
||||
- `TypeMachineComponentRequirement.required` corrigé
|
||||
|
||||
#### 2. Migration DB inventory-data → inventory ✅
|
||||
- **Dump data-only + normalisation** (conversion des identifiants quoted vers lowercase)
|
||||
- **Mapping table Prisma** : `"ModelType"` → `model_types`
|
||||
- **Exclusions** : `profiles`, `_prisma_migrations`
|
||||
- **Import validé** : `Counts match for all tables.`
|
||||
|
||||
Scripts utiles :
|
||||
```bash
|
||||
scripts/normalize-dump.py
|
||||
scripts/validate-migration.php
|
||||
```
|
||||
|
||||
#### 3. Frontend basculé sur Inventory_frontend ✅
|
||||
- `make dev-nuxt` pointe vers `Inventory_frontend/`
|
||||
- `README.md` mis à jour
|
||||
- **Base API** ajustée : `http://localhost:8081/api`
|
||||
|
||||
Fichiers modifiés :
|
||||
```
|
||||
makefile
|
||||
README.md
|
||||
Inventory_frontend/.env
|
||||
Inventory_frontend/nuxt.config.ts
|
||||
Inventory_frontend/app/services/modelTypes.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# pgAdmin
|
||||
PGADMIN_EMAIL=admin@admin.com
|
||||
PGADMIN_PASSWORD=admin
|
||||
PGADMIN_PORT=5050
|
||||
|
||||
# Symfony
|
||||
APP_ENV=dev
|
||||
APP_SECRET=changeme_super_secret_key_123456789
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=your_jwt_passphrase_change_me
|
||||
|
||||
# NestJS (pour futur parallèle)
|
||||
NESTJS_PORT=3000
|
||||
SESSION_SECRET=changeme_session_secret
|
||||
CORS_ORIGIN=http://localhost:3001
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚧 Phase 2 : Debugging & Tests (EN COURS)
|
||||
|
||||
### Objectifs
|
||||
- [x] Résoudre problème Apache `.htaccess`
|
||||
- [ ] Tester authentification JWT complète
|
||||
- [ ] Créer endpoint de test public fonctionnel
|
||||
- [ ] Documenter la solution Apache
|
||||
|
||||
### Prochaines étapes
|
||||
1. **Fix Apache** : Logs de debug + test FallbackResource
|
||||
2. **Test JWT** : Login + génération token + route protégée
|
||||
3. **Documentation** : Documenter la config Apache qui fonctionne
|
||||
|
||||
---
|
||||
|
||||
## 📊 Métriques
|
||||
|
||||
### Temps passé
|
||||
- **Phase 1** : ~3h (exploration + setup + debugging)
|
||||
- **Problème Apache** : ~1h30 (en cours)
|
||||
|
||||
### Fichiers créés/modifiés
|
||||
|
||||
**Nouveaux fichiers** :
|
||||
- `src/Entity/Profile.php`
|
||||
- `src/Repository/ProfileRepository.php`
|
||||
- `src/Controller/TestController.php`
|
||||
- `public/.htaccess`
|
||||
- `config/routes/routing.controllers.yaml`
|
||||
- `create_test_user.php` (script utilitaire)
|
||||
- `migrations/Version20260110175413.php`
|
||||
- `docker/pgadmin/servers.json` (config serveur PostgreSQL)
|
||||
- `docker/pgadmin/pgpass` (credentials PostgreSQL)
|
||||
- `CARNET_DE_BORD.md` (ce fichier)
|
||||
|
||||
**Fichiers modifiés** :
|
||||
- `docker-compose.yml` (+ pgAdmin)
|
||||
- `docker/.env.docker.local` (+ variables Symfony/JWT/pgAdmin)
|
||||
- `docker/php/config/vhost.conf` (DocumentRoot → public/)
|
||||
- `config/packages/security.yaml` (JWT firewalls)
|
||||
- `config/routes.yaml` (+ api_login_check)
|
||||
- `composer.json` (+ lexik JWT, vich uploader)
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Leçons Apprises
|
||||
|
||||
### 1. Symfony 8 + API Platform
|
||||
- **Attributs PHP 8** : `use Symfony\Component\Routing\Attribute\Route;` (pas `Annotation`)
|
||||
- **Routes controllers** : Nécessite `config/routes/routing.controllers.yaml` avec `type: attribute`
|
||||
- **API Platform** : Auto-génère les endpoints CRUD avec `#[ApiResource]`
|
||||
|
||||
### 2. JWT Authentication
|
||||
- **3 composants** :
|
||||
1. Firewall `login` : `json_login` intercepte `/api/login_check`
|
||||
2. Firewall `api` : `jwt` vérifie le token sur `/api/*`
|
||||
3. Access control : `PUBLIC_ACCESS` vs `IS_AUTHENTICATED_FULLY`
|
||||
- **username_path** : Permet de mapper `email` au lieu de `username`
|
||||
- **Provider** : Doit être défini dans le firewall `login`
|
||||
|
||||
### 3. Doctrine Migrations
|
||||
- **ID Prisma CUID** : Garder en `string(30)` pour compatibilité
|
||||
- **Lifecycle callbacks** : `#[ORM\PrePersist]` pour `createdAt`/`updatedAt`
|
||||
- **UserInterface** : Nécessite `getUserIdentifier()`, `getRoles()`, `eraseCredentials()`
|
||||
|
||||
### 4. Docker & Apache
|
||||
- **`.htaccess` vs VirtualHost** : Le vhost peut override le `.htaccess`
|
||||
- **AllowOverride All** : Indispensable pour que `.htaccess` fonctionne
|
||||
- **FallbackResource** : Alternative au mod_rewrite dans `.htaccess`
|
||||
- **Debugging** : Tester avec PHP built-in server pour isoler le problème
|
||||
|
||||
---
|
||||
|
||||
## 📚 Ressources Utiles
|
||||
|
||||
### Accès aux Services
|
||||
|
||||
```
|
||||
🌐 pgAdmin: http://localhost:5050
|
||||
└─ Login: admin@admin.com / admin
|
||||
└─ Serveur: "Inventory PostgreSQL" (pré-configuré)
|
||||
└─ Database: inventory
|
||||
└─ Note: Le serveur PostgreSQL est automatiquement connecté au démarrage
|
||||
|
||||
🌐 API Platform: http://localhost:8081/api/
|
||||
└─ Docs: http://localhost:8081/api/docs (à venir)
|
||||
|
||||
🗄️ PostgreSQL: localhost:5433
|
||||
└─ User: root / root
|
||||
└─ Database: inventory
|
||||
```
|
||||
|
||||
### Commandes fréquentes
|
||||
|
||||
```bash
|
||||
# Symfony
|
||||
make shell # Entrer dans le container
|
||||
php bin/console cache:clear # Clear cache
|
||||
make cache-clear-full # Clear cache + purge var/cache
|
||||
php bin/console debug:router # Lister routes
|
||||
php bin/console debug:firewall # Lister firewalls
|
||||
php bin/console doctrine:migrations:migrate # Exécuter migrations
|
||||
|
||||
# Docker
|
||||
make start # Démarrer containers
|
||||
make stop # Arrêter containers
|
||||
docker logs -f php-inventory-apache # Logs Apache
|
||||
docker logs -f pgadmin-inventory # Logs pgAdmin
|
||||
docker exec php-inventory-apache bash # Shell root
|
||||
|
||||
# Tests API
|
||||
curl http://localhost:8081/api/ # Test API Platform
|
||||
curl -X POST http://localhost:8081/api/login_check \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"admin@admin.com","password":"admin123"}'
|
||||
```
|
||||
|
||||
### Documentation
|
||||
- [Lexik JWT Bundle](https://github.com/lexik/LexikJWTAuthenticationBundle/blob/2.x/Resources/doc/index.rst)
|
||||
- [API Platform Security](https://api-platform.com/docs/core/security/)
|
||||
- [Symfony Security](https://symfony.com/doc/current/security.html)
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Historique des Changements
|
||||
|
||||
### 2026-01-15 - Session 3
|
||||
- ✅ Filtre API Platform `category` sur `ModelType`
|
||||
- ✅ Normalisation des structures `ModelType` (structure ↔ skeleton)
|
||||
- ✅ Migration `custom_fields.options` en JSON
|
||||
- ✅ Ajout commande `make cache-clear-full`
|
||||
- ✅ Correctifs frontend: headers API Platform, pagination par catégorie, persistance tri
|
||||
|
||||
### 2026-01-10 - Session 2 (20h30)
|
||||
- ✅ Problème Apache résolu (routes fonctionnelles)
|
||||
- ✅ Phase 2 complète (JWT 100% opérationnel)
|
||||
- ✅ Authentification testée avec succès
|
||||
- ✅ Réorganisation projet (frontend/ + _archives/)
|
||||
- ✅ État des lieux dans MIGRATION_PLAN.md
|
||||
- ✅ 5 commits conventionnels créés
|
||||
- 📊 Base inventory-data analysée (673 lignes)
|
||||
|
||||
### 2026-01-10 - Session 1 (19h00)
|
||||
- ✅ Création projet migration
|
||||
- ✅ Phase 1 complète (pgAdmin, JWT, Profile, migrations)
|
||||
- ⚠️ Problème Apache identifié (routes 404)
|
||||
- 📝 Carnet de bord créé
|
||||
|
||||
---
|
||||
|
||||
**Dernière mise à jour** : 2026-01-15 13:45
|
||||
**Statut** : Phase 3 EN COURS ⚠️ - Migrations et intégration frontend
|
||||
81
CHANGELOG.md
81
CHANGELOG.md
@@ -1,15 +1,78 @@
|
||||
# Changelog
|
||||
|
||||
Liste des évolutions du projet inventory
|
||||
## [1.8.1] - 2026-03-05
|
||||
|
||||
## [0.0.0]
|
||||
### Parameters
|
||||
Ajouter dans le fichier .env
|
||||
- DEFAULT_URI
|
||||
- DATABASE_URL
|
||||
### Ajouts
|
||||
- **Composant DataTable generique** : nouveau composant `DataTable.vue` + composable `useDataTable.ts` avec tri, recherche, pagination et filtres server-side. Toutes les pages catalogue (composants, pieces, produits, documents, constructeurs, commentaires, journal d'audit, admin) migrees vers ce composant partage.
|
||||
- **Messages d'erreur humanises** : les erreurs backend (violations de contraintes, erreurs serveur) sont desormais traduites en messages comprehensibles pour l'utilisateur final (`errorMessages.ts`).
|
||||
- **Icones Lucide dans la navbar** : reorganisation des groupes de navigation et ajout d'icones pour chaque section.
|
||||
- **Modal d'ajout d'entites aux machines** (`AddEntityToMachineModal.vue`) : ajout direct de composants, pieces et produits depuis la fiche machine.
|
||||
- **Filtres SearchFilter ipartial** sur les noms de types de modeles et commentaires cote API.
|
||||
|
||||
### Added
|
||||
### Refactoring
|
||||
- **Suppression du systeme TypeMachine (squelettes machines)** : les entites `TypeMachine`, `TypeMachineComponentRequirement`, `TypeMachinePieceRequirement`, `TypeMachineProductRequirement` sont supprimees avec leurs repositories et state processors. Les champs personnalises machines sont desormais lies directement a chaque machine (relation `CustomField → Machine`).
|
||||
- **Suppression des pages squelettes machines** : pages `/machine-skeleton`, `/type/[id]`, `/type/edit/[id]` et tous les composants associes (`TypeEditForm`, `MachineSkeletonSummary`, `MachineCreatePreview`, selectors de requirements, `useMachineTypesApi`, `useMachineSkeletonEditor`, `useMachineCreateSelections`, `useMachineCreatePreview`).
|
||||
- **Simplification de la creation de machines** : plus besoin de selectionner un squelette, ajout direct de composants/pieces/produits.
|
||||
- **Refactoring MachineStructureController** : remplacement de `MachineSkeletonController` par `MachineStructureController` avec gestion directe de la structure machine.
|
||||
- **Migration de toutes les tables vers DataTable** : suppression du code de tableau duplique dans chaque page au profit du composant generique.
|
||||
|
||||
### Changed
|
||||
### Corrections
|
||||
- **Suppression catalogue avec confirmation** : la suppression d'une piece ou d'un composant dans le catalogue affiche desormais une modale de confirmation listant les elements qui seront supprimes en cascade (documents, liaisons machine, valeurs de champs personnalises) au lieu de bloquer la suppression.
|
||||
- **Fix affichage categorie sur les pages edit** : les categories (produit, composant, piece) s'affichent correctement sur les pages d'edition au lieu de "Categorie inconnue". Cause : import `Serializer\Annotation\Groups` obsolete dans `ModelType` (remplace par `Attribute\Groups` pour Symfony 8) + groupes de serialisation manquants (`product:read`, `composant:read`, `piece:read`).
|
||||
- Fix import `Serializer\Annotation\Groups` → `Attribute\Groups` dans `Profile`.
|
||||
- Fix filtre `SearchFilter` : `partial` → `ipartial` sur `Comment.entityName` et `Document.name`/`Document.filename` pour recherche insensible a la casse.
|
||||
|
||||
### Fixed
|
||||
### Migration requise
|
||||
```bash
|
||||
docker compose exec web php bin/console doctrine:migrations:migrate
|
||||
```
|
||||
|
||||
## [1.8.0] - 2026-03-03
|
||||
|
||||
### Ajouts
|
||||
- **Stockage documents sur disque** : les documents sont desormais stockes en fichiers sur le systeme de fichiers au lieu de Base64 en base de donnees. Les endpoints `/api/documents/{id}/file` et `/api/documents/{id}/download` servent les fichiers directement.
|
||||
- **Commande de migration** `app:migrate-documents-to-filesystem` : migre les documents existants (Base64 → fichiers) avec dry-run, batch-size et limit.
|
||||
- **Pagination serveur sur la page Documents** : recherche, tri (date/nom/taille), filtre par rattachement (site/machine/composant/piece/produit), selecteur par page (20/50/100).
|
||||
- **Compression PDF automatique** : les documents PDF uploades sont compresses automatiquement via Ghostscript. Commande `app:compress-pdf` pour compresser les PDFs existants.
|
||||
- **Nettoyage automatique des fichiers** : suppression du fichier sur disque lors de la suppression d'un document.
|
||||
- **Champ description** sur les entites Piece et Composant, visible dans les catalogues avec popover au survol.
|
||||
|
||||
### Corrections
|
||||
- Fix normalisation des documents : `fileUrl` et `downloadUrl` toujours exposes dans l'API (meme sans `path` dans le groupe de serialisation).
|
||||
- Fix recursion infinie dans `DocumentNormalizer` (`getSupportedTypes` retourne `false` pour desactiver le cache).
|
||||
- Fix edition de squelettes machines : `deserialize: false` + `validate: false` sur le PUT pour eviter le conflit UniqueEntity et l'interference du deserialiseur avec les collections writableLink.
|
||||
- Fix sites : ajout operation PATCH et correction migration contrainte.
|
||||
- Retrocompatibilite : le controleur de service gere transparentement les anciens documents Base64 et les nouveaux fichiers.
|
||||
|
||||
### Migration requise
|
||||
```bash
|
||||
docker compose exec php php bin/console doctrine:migrations:migrate
|
||||
docker compose exec php php bin/console app:migrate-documents-to-filesystem
|
||||
```
|
||||
|
||||
## [1.7.0] - 2026-03-02
|
||||
|
||||
### Ajouts
|
||||
- **Systeme de commentaires / tickets** : les utilisateurs peuvent laisser des commentaires sur les fiches (machines, pieces, composants, produits, categories, squelettes). Les gestionnaires peuvent les resoudre.
|
||||
- **Page commentaires** (`/comments`) : vue centralisee avec filtres (statut, type d'entite), pagination et liens cliquables vers les fiches.
|
||||
- **Badge notifications** : compteur de commentaires ouverts sur l'avatar utilisateur et dans le menu profil (polling 60s).
|
||||
- **Controle d'acces par roles** : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages.
|
||||
- **Badge de role** dans le dropdown du profil utilisateur.
|
||||
- **Journal d'audit etendu** : audit logging sur machines, constructeurs, types de modeles, documents et conversions.
|
||||
- **Commande `app:init-profile-passwords`** : initialisation en masse des mots de passe et roles.
|
||||
|
||||
### Corrections
|
||||
- Toggle switch pour les champs personnalises booleens (remplace les checkboxes).
|
||||
- Recherche constructeur : filtrage cote client au lieu d'appels API debounce.
|
||||
- Prevention des doublons de noms de constructeurs et de references de pieces (contraintes unique).
|
||||
- Fix creation de squelettes machines : pagination, duplication, champs personnalises.
|
||||
|
||||
### Migration requise
|
||||
```bash
|
||||
docker compose exec php php bin/console doctrine:migrations:migrate
|
||||
docker compose exec php php bin/console app:init-profile-passwords
|
||||
```
|
||||
|
||||
## [1.6.0] - 2026-02-12
|
||||
|
||||
- Version initiale avec gestion du parc machines, pieces, composants, produits et categories.
|
||||
|
||||
233
CLAUDE.md
Normal file
233
CLAUDE.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# CLAUDE.md — Inventory Project
|
||||
|
||||
## Project Overview
|
||||
|
||||
Application de gestion d'inventaire industriel (machines, pièces, composants, produits).
|
||||
Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Tech | Version |
|
||||
|-------|------|---------|
|
||||
| Backend | Symfony + API Platform | 8.0 / ^4.2 |
|
||||
| PHP | PHP | >=8.4 |
|
||||
| Database | PostgreSQL | 16 |
|
||||
| Frontend | Nuxt (SPA, SSR off) | 4 |
|
||||
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 |
|
||||
| CSS | TailwindCSS 4 + DaisyUI 5 | |
|
||||
| Auth | Session-based (cookies, pas JWT) | |
|
||||
| Containers | Docker Compose | |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Inventory/ # Backend Symfony (repo principal)
|
||||
├── src/Entity/ # Entités Doctrine (annotations PHP 8 attributes)
|
||||
├── src/Controller/ # Controllers custom (session, comments, audit…)
|
||||
├── src/EventSubscriber/ # Audit subscribers (onFlush)
|
||||
├── config/ # Config Symfony
|
||||
├── migrations/ # Migrations Doctrine (raw SQL PostgreSQL)
|
||||
├── docker/ # Dockerfile + .env.docker
|
||||
├── scripts/ # release.sh, normalize-dump.py
|
||||
├── fixtures/ # SQL fixtures
|
||||
├── tests/ # PHPUnit
|
||||
├── pre-commit, commit-msg # Git hooks
|
||||
├── makefile # Commandes Docker/dev
|
||||
├── VERSION # Source unique de version (semver)
|
||||
├── Inventory_frontend/ # ← SUBMODULE GIT (repo séparé)
|
||||
│ ├── app/pages/ # Pages Nuxt (file-based routing)
|
||||
│ ├── app/components/ # Composants Vue (auto-imported)
|
||||
│ ├── app/composables/ # Composables Vue
|
||||
│ ├── app/shared/ # Types, utils, validation
|
||||
│ ├── app/middleware/ # Auth middleware global
|
||||
│ └── app/services/ # Service layer (wrappers useApi)
|
||||
```
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
# Docker
|
||||
make start # Démarrer les containers
|
||||
make stop # Arrêter
|
||||
make shell # Shell interactif (nécessite un TTY)
|
||||
make install # Install complet (composer + npm + build)
|
||||
|
||||
# Backend
|
||||
make test # PHPUnit (tous les tests)
|
||||
make test FILES=tests/Api/Entity/MachineTest.php # Un test spécifique
|
||||
make php-cs-fixer-allow-risky # Linter PHP (cs-fixer)
|
||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate
|
||||
|
||||
# Frontend (dans Inventory_frontend/)
|
||||
npm run dev # Dev server (port 3001)
|
||||
npm run build # Build production
|
||||
npm run lint:fix # ESLint fix
|
||||
npx nuxi typecheck # TypeScript check (0 errors attendu)
|
||||
|
||||
# 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)
|
||||
```
|
||||
|
||||
## Git Conventions
|
||||
|
||||
### Branches
|
||||
- `master` — production
|
||||
- `develop` — branche principale de dev (cible des PR)
|
||||
- `feat/xxx`, `fix/xxx`, `refactor/xxx` — branches de travail
|
||||
|
||||
### Commit Message Format (enforced by hook)
|
||||
```
|
||||
<type>(<scope optionnel>) : <message>
|
||||
```
|
||||
**Espace obligatoire autour du `:`**. Types autorisés (minuscules) :
|
||||
`build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`, `wip`
|
||||
|
||||
Exemples :
|
||||
- `feat(auth) : add login page`
|
||||
- `fix(machines) : prevent null crash on skeleton creation`
|
||||
|
||||
### Pre-commit Hook
|
||||
1. php-cs-fixer sur les fichiers PHP stagés
|
||||
2. PHPUnit — bloque le commit si tests échouent
|
||||
|
||||
### Submodule Workflow
|
||||
Le frontend est un submodule git. Lors d'un commit frontend :
|
||||
1. Commit dans `Inventory_frontend/` d'abord
|
||||
2. Commit dans le repo principal pour mettre à jour le pointeur submodule
|
||||
3. Push les deux repos
|
||||
|
||||
## Architecture Backend
|
||||
|
||||
### Entités Principales
|
||||
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
|
||||
|
||||
#### 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
|
||||
- **ORM** : Attributs PHP 8 (`#[ORM\Column(...)]`, `#[Groups([...])]`)
|
||||
- **Lifecycle** : `#[ORM\HasLifecycleCallbacks]` avec `PrePersist`/`PreUpdate` pour `createdAt`/`updatedAt`
|
||||
- **Sécurité** : `security: "is_granted('ROLE_...')"` sur chaque opération API Platform
|
||||
- **Audit** : Subscribers Doctrine `onFlush` capturent diff + snapshot complet
|
||||
- **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence
|
||||
|
||||
### Custom Controllers (pas API Platform)
|
||||
- `MachineStructureController` — `/api/machines/{id}/structure` (GET/PATCH), `/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
|
||||
```
|
||||
|
||||
### PostgreSQL — ATTENTION
|
||||
- Les noms de colonnes sont **TOUJOURS EN MINUSCULES** dans PG
|
||||
- Doctrine utilise camelCase (`typePieceId`) mais PG stocke `typepieceid`
|
||||
- Le SQL brut doit utiliser les noms lowercase
|
||||
- Tables de jointure many-to-many : colonnes `a` et `b` (ex: `_piececonstructeurs`)
|
||||
|
||||
## Architecture Frontend
|
||||
|
||||
### Patterns
|
||||
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
|
||||
- **Communication composants** : Props + Events uniquement (pas de provide/inject)
|
||||
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session
|
||||
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
|
||||
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`
|
||||
- **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client
|
||||
- **Auto-imports** : Nuxt auto-importe composants (`components/`) et composables (`composables/`)
|
||||
|
||||
### DaisyUI Classes
|
||||
- Input : `input input-bordered input-sm md:input-md`
|
||||
- Textarea : `textarea textarea-bordered textarea-sm md:textarea-md`
|
||||
- Select : `select select-bordered select-sm md:select-md`
|
||||
- Button : `btn btn-sm md:btn-md btn-primary`
|
||||
|
||||
## Règles Importantes
|
||||
|
||||
### CLAUDE.md — Maintenance obligatoire
|
||||
- **Toujours consulter** ce fichier en début de conversation pour respecter les conventions
|
||||
- **Mettre à jour** ce fichier quand une nouvelle convention, pattern ou décision architecturale est établie
|
||||
- **Utiliser comme source de vérité** pour les commandes, patterns et règles du projet
|
||||
|
||||
### Toujours faire AVANT de modifier du code
|
||||
1. **Lire le fichier** avant de l'éditer — ne jamais proposer de changements sur du code non lu
|
||||
2. **Comprendre le pattern existant** — reproduire le style du fichier (noms, indentation, structure)
|
||||
3. **Vérifier les deux repos** — un changement peut impacter backend ET frontend
|
||||
|
||||
### Après chaque modification
|
||||
1. Backend PHP : `make php-cs-fixer-allow-risky`
|
||||
2. Frontend : `npm run lint:fix` puis `npx nuxi typecheck` si fichiers TS modifiés
|
||||
|
||||
### Ne jamais faire
|
||||
- Ajouter des features non demandées, du code mort, ou des abstractions prématurées
|
||||
- Utiliser `provide/inject` — le codebase utilise Props + Events
|
||||
- Utiliser JWT/tokens — l'auth est session-based
|
||||
- Écrire du SQL avec des noms camelCase — PostgreSQL = lowercase
|
||||
- Committer sans que l'utilisateur le demande explicitement
|
||||
- Force push sans confirmation explicite
|
||||
- Modifier la config git
|
||||
|
||||
### Submodule — Synchronisation
|
||||
Quand les branches `master` et `develop` divergent sur l'un des deux repos, **toujours les synchroniser** :
|
||||
- Main repo : `git checkout master && git merge develop && git push`
|
||||
- Frontend : `git checkout develop && git merge master && git push` (ou l'inverse selon le cas)
|
||||
|
||||
## Tests
|
||||
|
||||
### Stack de test
|
||||
- **PHPUnit 12** + **API Platform Test** (`ApiTestCase`)
|
||||
- **DAMA DoctrineTestBundle** — wrappe chaque test dans une transaction avec rollback automatique (pas de TRUNCATE)
|
||||
- Base de test : même PG, env `test`
|
||||
|
||||
### Commandes
|
||||
Voir section "Key Commands". Commande additionnelle :
|
||||
```bash
|
||||
make test-setup # Créer/mettre à jour le schéma test
|
||||
```
|
||||
|
||||
### Pattern de test
|
||||
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
|
||||
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
|
||||
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `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`
|
||||
- Adminer (PG) : `http://localhost:5050`
|
||||
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
|
||||
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: 62127a33f5...428da471d1
1419
MIGRATION_PLAN.md
1419
MIGRATION_PLAN.md
File diff suppressed because it is too large
Load Diff
353
README.md
353
README.md
@@ -1,92 +1,305 @@
|
||||
# Projet Inventory
|
||||
# Inventory
|
||||
|
||||
## Installation du projet
|
||||
### Windows
|
||||
Pour windows, il faut installer le WSL2, Ubuntu, docker et nvm.
|
||||
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows)
|
||||
Application de gestion d'inventaire industriel pour **Malio**. Gestion complète du parc machines, des pièces, composants, produits, fournisseurs et documents associés, avec traçabilité et contrôle d'accès par rôles.
|
||||
|
||||
### Linux
|
||||
Pour linux, il faut installer docker et nvm.
|
||||
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux)
|
||||
## C'est quoi ce projet ?
|
||||
|
||||
Inventory est une application web qui permet de gérer un parc de machines industrielles. Concrètement, elle permet de :
|
||||
|
||||
- **Cataloguer** les machines d'une usine, site par site
|
||||
- **Décomposer** chaque machine en composants, pièces et produits (structure arborescente)
|
||||
- **Suivre** les fournisseurs/constructeurs de chaque élément
|
||||
- **Stocker** les documents techniques (PDF, images, fiches techniques)
|
||||
- **Tracer** toutes les modifications (qui a changé quoi, quand) via un journal d'audit
|
||||
- **Commenter** les fiches pour collaborer entre équipes
|
||||
- **Gérer les accès** avec un système de rôles (admin, gestionnaire, lecteur)
|
||||
|
||||
L'application se compose de deux parties :
|
||||
- Un **backend** (API REST) qui gère les données, la sécurité et la logique métier
|
||||
- Un **frontend** (interface web) qui affiche les données et permet l'interaction utilisateur
|
||||
|
||||
## Stack technique
|
||||
|
||||
| Couche | Technologie | Version | Rôle |
|
||||
|--------|-------------|---------|------|
|
||||
| Backend | Symfony + API Platform | 8.0 / 4.2 | API REST, logique métier, sécurité |
|
||||
| PHP | PHP | >= 8.4 | Langage backend |
|
||||
| Base de données | PostgreSQL | 16 | Stockage des données |
|
||||
| Frontend | Nuxt (SPA, SSR off) | 4 | Framework web (rendu côté client) |
|
||||
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 | Composants d'interface |
|
||||
| CSS | TailwindCSS + DaisyUI | 4 / 5 | Mise en page et composants visuels |
|
||||
| Conteneurs | Docker Compose | | Environnement de développement |
|
||||
|
||||
## Prérequis
|
||||
|
||||
- **Docker** et **Docker Compose** (pour lancer le projet sans rien installer)
|
||||
- **Node.js** >= 20 (via [nvm](https://github.com/nvm-sh/nvm))
|
||||
- **make** (normalement déjà installé sur Linux/macOS)
|
||||
|
||||
### Guides d'installation de l'environnement
|
||||
|
||||
| OS | Documentation |
|
||||
|----|---------------|
|
||||
| Windows | [WSL2 + Ubuntu + Docker](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows) |
|
||||
| Linux | [Docker + nvm](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux) |
|
||||
|
||||
## Installation rapide
|
||||
|
||||
### Installation du projet
|
||||
Une fois les prérequis installés, il suffit de cloner le projet et de lancer les commandes suivantes
|
||||
```bash
|
||||
sudo apt install make -y
|
||||
# 1. Cloner le projet avec le frontend (submodule)
|
||||
git clone --recurse-submodules <url-du-repo>
|
||||
cd Inventory
|
||||
|
||||
# 2. Démarrer les conteneurs Docker (PHP, PostgreSQL, Adminer)
|
||||
make start
|
||||
|
||||
# 3. Installer les dépendances et builder le projet
|
||||
make install
|
||||
```
|
||||
Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifier **POSTGRES_PORT** dans le fichier .env.docker.local, remplacer le par un port disponible.
|
||||
|
||||
### Configuration xdebug
|
||||
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
|
||||
Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
|
||||
* Name : inventory-docker
|
||||
* Host : localhost
|
||||
* Port : 8080
|
||||
* Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
|
||||
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `docker/.env.docker.local`.
|
||||
|
||||
Pour que xdebug fonctionne sur windows, il faut modifier la variable **XDEBUG_CLIENT_HOST** par votre ip local
|
||||
### Que fait `make install` ?
|
||||
|
||||
## Utilisation du projet
|
||||
### Backend
|
||||
L'api est disponible sur http://localhost:8080/api
|
||||
Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local**
|
||||
Vous pouvez modifier le port si nécessaire.
|
||||
1. Installe les dépendances PHP (via Composer)
|
||||
2. Installe les dépendances Node.js (via npm)
|
||||
3. Build le frontend Nuxt
|
||||
|
||||
La bdd est déja pré-configuré dans PhpStorm, il suffit de rentrer les infos du .env.docker.local pour se connecter.
|
||||
C'est un bdd local dans le docker.
|
||||
### Frontend
|
||||
Le frontend utilise le dossier `Inventory_frontend/`.
|
||||
Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev
|
||||
```bash
|
||||
make dev-nuxt
|
||||
```
|
||||
Le front sera accessible sur http://localhost:3000
|
||||
### Premier lancement
|
||||
|
||||
## Compression automatique des PDFs
|
||||
Une fois l'installation terminée, tu peux :
|
||||
|
||||
Les documents PDF uploadés sont automatiquement compressés sans perte de qualité grâce à **qpdf**.
|
||||
1. Charger des données de test : `make fixtures-load`
|
||||
2. Lancer le frontend en mode dev : `make dev-nuxt`
|
||||
3. Ouvrir l'application : http://localhost:3001
|
||||
|
||||
### Prérequis
|
||||
```bash
|
||||
# Installation de qpdf (outil système)
|
||||
sudo apt install qpdf
|
||||
## URLs locales
|
||||
|
||||
# Ou dans Docker
|
||||
docker exec -it php-inventory-apache apt update && apt install -y qpdf
|
||||
```
|
||||
|
||||
### Fonctionnement
|
||||
- À chaque upload de PDF, le système compresse automatiquement le fichier
|
||||
- Compression lossless (sans perte de qualité)
|
||||
- Le PDF est compressé uniquement si la taille diminue
|
||||
- Si qpdf n'est pas installé, le système fonctionne normalement sans compression
|
||||
|
||||
### Compresser les PDFs existants
|
||||
Pour compresser tous les PDFs déjà en base :
|
||||
```bash
|
||||
# Voir ce qui serait compressé (dry-run)
|
||||
php bin/console app:compress-pdf --dry-run
|
||||
|
||||
# Compresser tous les PDFs
|
||||
php bin/console app:compress-pdf
|
||||
```
|
||||
| Service | URL | Description |
|
||||
|---------|-----|-------------|
|
||||
| API Symfony | http://localhost:8081/api | Documentation interactive de l'API (Swagger) |
|
||||
| Frontend Nuxt | http://localhost:3001 | L'application web |
|
||||
| Adminer (BDD) | http://localhost:5050 | Interface web pour explorer la base de données |
|
||||
| PostgreSQL | `localhost:5433` | Connexion directe (user: root, pass: root, db: inventory) |
|
||||
|
||||
## Commandes utiles
|
||||
Pour restart le container
|
||||
|
||||
### Docker
|
||||
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| `make start` | Démarrer les conteneurs |
|
||||
| `make stop` | Arrêter les conteneurs |
|
||||
| `make restart` | Redémarrer les conteneurs |
|
||||
| `make shell` | Ouvrir un terminal dans le conteneur PHP (pour lancer des commandes Symfony) |
|
||||
| `make reset` | Reset complet (supprime les volumes, réinstalle tout) |
|
||||
|
||||
### Backend
|
||||
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| `make test` | Lancer les tests PHPUnit |
|
||||
| `make test FILES=tests/Api/Entity/MachineTest.php` | Lancer un test spécifique |
|
||||
| `make test-setup` | Créer/mettre à jour la base de test |
|
||||
| `make php-cs-fixer-allow-risky` | Formatter le code PHP (indentation, espaces, etc.) |
|
||||
| `make cache-clear` | Vider le cache Symfony (à faire si tu as des erreurs bizarres) |
|
||||
| `make db-reset` | Reset de la BDD (supprime toutes les données) |
|
||||
| `make fixtures-load` | Charger les données de test |
|
||||
| `make fixtures-dump` | Sauvegarder la BDD actuelle dans fixtures/data.sql |
|
||||
|
||||
### Frontend
|
||||
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| `make dev-nuxt` | Lancer le serveur de dev Nuxt (avec rechargement automatique) |
|
||||
| `make build-nuxtJS` | Builder le frontend pour la production |
|
||||
|
||||
### Release
|
||||
|
||||
```bash
|
||||
make restart
|
||||
./scripts/release.sh patch # Bump patch (ou minor / major)
|
||||
```
|
||||
Pour lancer les TU
|
||||
```bash
|
||||
make test
|
||||
|
||||
Synchronise automatiquement la version dans `VERSION`, `api_platform.yaml` et `nuxt.config.ts`, crée le tag git et pousse les deux repos.
|
||||
|
||||
## Architecture globale
|
||||
|
||||
### Comment ça marche ?
|
||||
|
||||
```
|
||||
Pour accéder au container et lance des commandes
|
||||
```bash
|
||||
make shell
|
||||
┌──────────────────┐ HTTP (JSON) ┌──────────────────┐ SQL ┌────────────┐
|
||||
│ Frontend │ ◄─────────────────► │ Backend │ ◄──────────► │ PostgreSQL │
|
||||
│ (Nuxt/Vue) │ cookies session │ (Symfony/API) │ │ (BDD) │
|
||||
│ localhost:3001 │ │ localhost:8081 │ │ port 5433 │
|
||||
└──────────────────┘ └──────────────────┘ └────────────┘
|
||||
│ │
|
||||
Interface web API REST + logique
|
||||
pour l'utilisateur métier + sécurité
|
||||
```
|
||||
Pour clear le cache Symfony
|
||||
```bash
|
||||
make cache-clear
|
||||
|
||||
1. L'utilisateur ouvre le navigateur sur `localhost:3001`
|
||||
2. Le frontend (Vue/Nuxt) affiche l'interface
|
||||
3. Quand l'utilisateur fait une action (créer une machine, etc.), le frontend envoie une requête HTTP à l'API backend
|
||||
4. Le backend valide la requête, vérifie les permissions, exécute la logique métier
|
||||
5. Le backend lit/écrit dans PostgreSQL et renvoie une réponse JSON
|
||||
6. Le frontend met à jour l'interface avec les nouvelles données
|
||||
|
||||
### Structure du projet
|
||||
|
||||
```
|
||||
Inventory/ # Backend Symfony (repo principal)
|
||||
├── src/
|
||||
│ ├── Entity/ # Les "modèles" de données (Machine, Piece, etc.)
|
||||
│ ├── Controller/ # Les endpoints API personnalisés
|
||||
│ ├── EventSubscriber/ # Logique déclenchée automatiquement (audit, etc.)
|
||||
│ ├── Command/ # Commandes CLI (lancer via php bin/console)
|
||||
│ ├── Service/ # Services métier (stockage fichiers, PDF, etc.)
|
||||
│ ├── State/ # Processeurs API Platform (hashage mot de passe, upload)
|
||||
│ ├── Repository/ # Requêtes BDD personnalisées
|
||||
│ ├── Security/ # Authentification par session
|
||||
│ └── Serializer/ # Conversion entité ↔ JSON personnalisée
|
||||
├── config/ # Configuration Symfony (routes, sécurité, etc.)
|
||||
├── migrations/ # Scripts de modification de la BDD
|
||||
├── fixtures/ # Données de test (SQL)
|
||||
├── tests/ # Tests automatisés (PHPUnit)
|
||||
├── scripts/ # Utilitaires (release, migration, normalisation)
|
||||
├── docker/ # Dockerfile + config Docker
|
||||
├── makefile # Commandes de dev raccourcies
|
||||
├── VERSION # Version courante (ex: 1.8.1)
|
||||
└── Inventory_frontend/ # Submodule git (frontend, repo séparé)
|
||||
├── app/pages/ # Les pages de l'app (1 fichier = 1 route URL)
|
||||
├── app/components/ # Composants Vue réutilisables
|
||||
├── app/composables/ # Logique métier partagée (appels API, états)
|
||||
├── app/shared/ # Types TypeScript, utilitaires, validation
|
||||
├── app/middleware/ # Vérification de session automatique
|
||||
└── app/services/ # Couche service (wrappers API)
|
||||
```
|
||||
|
||||
### Entités principales (les "tables" de la BDD)
|
||||
|
||||
| Entité | Description | Exemple |
|
||||
|--------|-------------|---------|
|
||||
| `Machine` | Machines du parc industriel | "CNC Mazak 01" |
|
||||
| `Composant` | Composants fonctionnels d'une machine | "Broche principale" |
|
||||
| `Piece` | Pièces détachées/de rechange | "Roulement SKF 6205" |
|
||||
| `Product` | Produits fournisseur (consommables, outillage) | "Huile de coupe X" |
|
||||
| `Site` | Sites physiques / usines | "Usine de Strasbourg" |
|
||||
| `Constructeur` | Fournisseurs / fabricants | "SKF", "Mazak" |
|
||||
| `ModelType` | Catégories avec squelettes de structure | "Type: Moteur électrique" |
|
||||
| `CustomField` / `CustomFieldValue` | Champs personnalisés (dynamiques) | "Tension : 220V" |
|
||||
| `Document` | Documents uploadés (PDF, images, etc.) | "Fiche technique CNC.pdf" |
|
||||
| `AuditLog` | Journal d'audit (historique des modifications) | "Machine X modifiée par Jean" |
|
||||
| `Comment` | Commentaires / tickets sur les fiches | "Vérifier le roulement" |
|
||||
| `Profile` | Comptes utilisateurs avec rôles | "admin@malio.fr (ADMIN)" |
|
||||
|
||||
### Structure hiérarchique d'une machine
|
||||
|
||||
Une machine peut contenir une arborescence de composants, pièces et produits :
|
||||
|
||||
```
|
||||
Machine "CNC Mazak 01"
|
||||
├── Composant "Broche principale"
|
||||
│ ├── Pièce "Roulement avant"
|
||||
│ │ └── Produit "Graisse SKF LGMT2"
|
||||
│ └── Pièce "Joint d'étanchéité"
|
||||
├── Composant "Système hydraulique"
|
||||
│ ├── Pièce "Pompe HP"
|
||||
│ └── Produit "Huile hydraulique ISO 46"
|
||||
└── Produit "Filtre à air cabine"
|
||||
```
|
||||
|
||||
### Rôles et permissions
|
||||
|
||||
```
|
||||
ROLE_ADMIN → Tout faire + gérer les utilisateurs
|
||||
↓ hérite de
|
||||
ROLE_GESTIONNAIRE → Créer, modifier, supprimer les données
|
||||
↓ hérite de
|
||||
ROLE_VIEWER → Lecture seule sur toutes les données
|
||||
↓ hérite de
|
||||
ROLE_USER → Accès de base (rôle minimum)
|
||||
```
|
||||
|
||||
### Authentification
|
||||
|
||||
Authentification par **session (cookies)**, pas de JWT. Le profil actif est stocké en session côté serveur. Concrètement :
|
||||
|
||||
1. L'utilisateur choisit son profil sur la page de login
|
||||
2. Il entre son mot de passe
|
||||
3. Le backend crée une session et envoie un cookie au navigateur
|
||||
4. À chaque requête suivante, le navigateur envoie automatiquement ce cookie
|
||||
5. Le backend vérifie le cookie et identifie l'utilisateur
|
||||
|
||||
### Base de données — Points importants
|
||||
|
||||
PostgreSQL 16 avec les particularités suivantes :
|
||||
- **IDs** : chaînes CUID (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
|
||||
- **Noms de colonnes** : toujours en **minuscules** dans PostgreSQL (Doctrine map `typePieceId` → `typepieceid`)
|
||||
- **Audit** : les subscribers Doctrine `onFlush` capturent le diff + snapshot complet de chaque modification
|
||||
- **Migrations** : SQL brut avec `IF NOT EXISTS` / `IF EXISTS` pour l'idempotence
|
||||
|
||||
## Services Docker
|
||||
|
||||
| Service | Image | Port | Rôle |
|
||||
|---------|-------|------|------|
|
||||
| `web` | PHP 8.4 + Apache + Node | 8081, 3001 | API Symfony + Nuxt dev |
|
||||
| `db` | PostgreSQL 16 Alpine | 5433 | Base de données |
|
||||
| `adminer` | Adminer | 5050 | Interface web pour explorer la BDD |
|
||||
|
||||
## Xdebug
|
||||
|
||||
Configuration PhpStorm / VSCode :
|
||||
- **Serveur** : `inventory-docker`
|
||||
- **Host** : `localhost`
|
||||
- **Port** : `8081`
|
||||
- **Path mapping** : racine du projet → `/var/www/html`
|
||||
|
||||
> Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `docker/.env.docker.local` avec votre IP locale.
|
||||
|
||||
## Git
|
||||
|
||||
### Branches
|
||||
|
||||
- `master` : production
|
||||
- `develop` : branche principale de dev (cible des PR)
|
||||
- `feat/xxx`, `fix/xxx`, `refactor/xxx` : branches de travail
|
||||
|
||||
### Convention de commit (enforced par un hook)
|
||||
|
||||
```
|
||||
<type>(<scope>) : <message>
|
||||
```
|
||||
|
||||
**Espace obligatoire autour du `:`**. Types autorisés (minuscules) :
|
||||
`feat`, `fix`, `perf`, `refactor`, `chore`, `docs`, `test`, `style`, `build`, `ci`, `revert`, `wip`
|
||||
|
||||
Exemples :
|
||||
```
|
||||
feat(machines) : add clone functionality
|
||||
fix(documents) : prevent duplicate upload
|
||||
refactor(audit) : merge history controllers
|
||||
chore(deps) : update composer packages
|
||||
```
|
||||
|
||||
### Pre-commit hook
|
||||
|
||||
Le hook `pre-commit` s'exécute automatiquement avant chaque commit :
|
||||
1. **php-cs-fixer** — Formate automatiquement les fichiers PHP modifiés
|
||||
2. **PHPUnit** — Lance les tests. Si un test échoue, le commit est bloqué
|
||||
|
||||
### Submodule frontend
|
||||
|
||||
Le frontend est un **submodule git** dans `Inventory_frontend/` (c'est un repo git séparé, inclus dans le repo principal). Workflow de commit :
|
||||
|
||||
1. Commiter dans `Inventory_frontend/` d'abord
|
||||
2. Commiter dans le repo principal pour mettre à jour le pointeur du submodule
|
||||
3. Pousser les deux repos
|
||||
|
||||
## Documentation détaillée
|
||||
|
||||
- **[docs/BACKEND.md](docs/BACKEND.md)** : guide complet du backend (entités, controllers, API, audit, tests)
|
||||
- **[docs/FRONTEND.md](docs/FRONTEND.md)** : guide complet du frontend (pages, composables, composants, patterns)
|
||||
- **[DEPLOY.md](DEPLOY.md)** : guide de déploiement serveur (Nginx, PHP-FPM, PostgreSQL)
|
||||
- **[RELEASE.md](RELEASE.md)** : processus de release et versioning
|
||||
- **[CHANGELOG.md](CHANGELOG.md)** : historique des versions
|
||||
- **[Frontend README](Inventory_frontend/README.md)** : documentation du frontend Nuxt
|
||||
|
||||
@@ -1,786 +0,0 @@
|
||||
# Plan de Refactoring - Inventory v1.2.0
|
||||
|
||||
> **Date de creation :** 2026-02-03
|
||||
> **Branche de travail :** `refacto/v1.3.0`
|
||||
> **Base :** `develop` (commit `8d83076`)
|
||||
|
||||
---
|
||||
|
||||
## Legende des statuts
|
||||
|
||||
| Statut | Signification |
|
||||
| ------ | ---------------------- |
|
||||
| `[ ]` | A faire |
|
||||
| `[~]` | En cours |
|
||||
| `[x]` | Termine |
|
||||
| `[!]` | Bloque / besoin d'info |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 - Securite (CRITIQUE)
|
||||
|
||||
> **Priorite :** MAXIMALE - A traiter en premier
|
||||
|
||||
### 1.1 Corriger la configuration de securite
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichier :** `config/packages/security.yaml`
|
||||
- **Probleme :** `PUBLIC_ACCESS` applique a toutes les routes `/api` avant la regle `IS_AUTHENTICATED_FULLY`. Le pattern matching "first match wins" rend potentiellement tout public.
|
||||
- **Action :** Reordonner les regles `access_control` pour que les routes protegees soient listees AVANT les routes publiques.
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
### 1.2 Ajouter les controles d'autorisation sur les controllers
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :**
|
||||
- `src/Controller/MachineSkeletonController.php`
|
||||
- `src/Controller/CustomFieldValueController.php`
|
||||
- `src/Controller/DocumentQueryController.php`
|
||||
- `src/Controller/SessionProfileController.php`
|
||||
- `src/Controller/SessionProfilesController.php`
|
||||
- Tous les `*HistoryController.php`
|
||||
- **Probleme :** Aucun attribut `#[IsGranted]` sur les controllers custom. Pas de RBAC.
|
||||
- **Action :** Ajouter `#[IsGranted('IS_AUTHENTICATED_FULLY')]` sur chaque controller (ou route). Definir des roles si necessaire.
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
### 1.3 Securiser les secrets
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :**
|
||||
- `.env` (JWT_PASSPHRASE en dur, APP_SECRET vide)
|
||||
- `docker/.env.docker` (credentials `root:root`)
|
||||
- **Action :**
|
||||
1. Deplacer `JWT_PASSPHRASE` dans `.env.local` (git-ignore)
|
||||
2. Generer un `APP_SECRET` valide
|
||||
3. Ajouter `.env.local` dans `.gitignore` si pas deja fait
|
||||
4. Documenter la configuration des secrets pour les devs
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 - Elimination de la duplication de code
|
||||
|
||||
> **Priorite :** HAUTE - Impact direct sur la maintenabilite
|
||||
|
||||
### 2.1 Refactorer les 3 Audit Subscribers en un seul generique
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers concernes :**
|
||||
- `src/EventSubscriber/ProductAuditSubscriber.php` (298 LOC)
|
||||
- `src/EventSubscriber/PieceAuditSubscriber.php` (300 LOC)
|
||||
- `src/EventSubscriber/ComposantAuditSubscriber.php` (300 LOC)
|
||||
- **Probleme :** ~900 LOC dupliquees a ~95%. Les methodes `onFlush()`, `buildDiffFromChangeSet()`, `resolveActorProfileId()`, `mergeDiffs()`, `normalizeCollection()` sont identiques. Seules les methodes `snapshot*()` different legerement.
|
||||
- **Action :**
|
||||
1. Creer un `AbstractAuditSubscriber` ou un `GenericAuditSubscriber` parametrable
|
||||
2. Extraire la logique commune (onFlush, buildDiff, resolveActor, mergeDiffs, normalizeCollection)
|
||||
3. Utiliser un systeme de configuration par entite (map `entityClass => entityType + snapshotMethod`)
|
||||
4. Supprimer les 3 fichiers redondants
|
||||
5. Verifier que l'audit fonctionne toujours sur Product, Piece et Composant
|
||||
- **Agent :** -
|
||||
- **Notes :** Tester manuellement les logs d'audit apres refacto.
|
||||
|
||||
### 2.2 Extraire un CuidGenerator utilitaire
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers concernes :** 18 entites contenant `generateCuid()` en prive
|
||||
- **Probleme :** Methode `generateCuid()` dupliquee dans chaque entite. De plus, `AuditLog.php` utilise une variante differente (base_convert).
|
||||
- **Action :**
|
||||
1. Creer `src/Util/CuidGenerator.php` avec une methode statique `generate(): string`
|
||||
2. Uniformiser l'implementation (choisir une seule methode)
|
||||
3. Remplacer tous les appels dans les 18 entites
|
||||
4. Supprimer les methodes privees devenues inutiles
|
||||
- **Agent :** -
|
||||
- **Notes :** Attention a l'inconsistance entre AuditLog et les autres entites.
|
||||
|
||||
### 2.3 Factoriser la logique de liaison dans MachineSkeletonController
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichier :** `src/Controller/MachineSkeletonController.php` (756 LOC)
|
||||
- **Probleme :** Les methodes `applyComponentLinks()`, `applyPieceLinks()`, `applyProductLinks()` sont quasi identiques (~90 LOC chacune).
|
||||
- **Action :**
|
||||
1. Extraire une methode generique `applyLinks(Machine $machine, array $links, string $type)`
|
||||
2. Parametrer par le type d'entite liee (Composant, Piece, Product)
|
||||
3. Reduire le controller a ~400 LOC max
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 - Restructuration des controllers
|
||||
|
||||
> **Priorite :** MOYENNE - Amelioration de la lisibilite et maintenabilite
|
||||
|
||||
### 3.1 Decouper MachineSkeletonController
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichier :** `src/Controller/MachineSkeletonController.php` (756 LOC)
|
||||
- **Action :**
|
||||
1. Extraire la logique metier dans un `MachineSkeletonService`
|
||||
2. Le controller ne doit gerer que la requete/reponse HTTP
|
||||
3. Le service gere la logique de skeleton (get, update, applyLinks)
|
||||
4. Extraire les helpers (`resolveIdentifier`, `indexLinksById`, `applyOverrides`, `normalizeMachineSkeletonResponse`) dans le service
|
||||
- **Agent :** -
|
||||
- **Notes :** Depend de la phase 2.3 (factorisation des liens).
|
||||
|
||||
### 3.2 Ajouter un try-catch et du logging dans les controllers
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :** Tous les controllers dans `src/Controller/`
|
||||
- **Probleme :** Aucun try-catch autour des `flush()` et `persist()`. Pas de logging d'erreurs.
|
||||
- **Action :**
|
||||
1. Ajouter `try-catch` autour des operations Doctrine dans chaque controller
|
||||
2. Logger les erreurs avec le `LoggerInterface` de Symfony (Monolog)
|
||||
3. Retourner des reponses JSON coherentes en cas d'erreur serveur (500)
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
### 3.3 Renforcer la validation des entrees
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :**
|
||||
- `src/Controller/CustomFieldValueController.php`
|
||||
- `src/Controller/MachineSkeletonController.php`
|
||||
- **Probleme :** Pas de validation de longueur max, pas de regex sur les IDs, pas de controle de profondeur JSON.
|
||||
- **Action :**
|
||||
1. Valider le format des IDs (regex CUID : `/^cl[a-f0-9]{24}$/`)
|
||||
2. Ajouter des limites de longueur sur les champs string
|
||||
3. Utiliser le composant Validator de Symfony pour les DTOs si pertinent
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 - Amelioration du stockage
|
||||
|
||||
> **Priorite :** MOYENNE - Performance et scalabilite
|
||||
|
||||
### 4.1 Migrer le stockage PDF de base64 vers le filesystem
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :**
|
||||
- `src/Entity/Document.php`
|
||||
- `src/Command/CompressPdfCommand.php`
|
||||
- `src/Service/PdfCompressorService.php`
|
||||
- **Probleme :** Les PDFs sont stockes en base64 dans la colonne `path` (TEXT) de la BDD. Risque de DoS et mauvaise perf sur des gros fichiers.
|
||||
- **Action :**
|
||||
1. Utiliser `vich/uploader-bundle` (deja installe) pour le stockage fichier
|
||||
2. Configurer un repertoire de stockage (`var/uploads/documents/`)
|
||||
3. Migrer les documents existants (script de migration)
|
||||
4. Adapter `PdfCompressorService` pour lire/ecrire sur le filesystem
|
||||
5. Mettre a jour l'entite Document
|
||||
- **Agent :** -
|
||||
- **Notes :** Prevoir une migration de donnees pour les documents existants.
|
||||
|
||||
### 4.2 Corriger les types de prix (string -> decimal)
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :**
|
||||
- `src/Entity/Machine.php` (`$prix`)
|
||||
- `src/Entity/Product.php` (`$supplierPrice`)
|
||||
- **Probleme :** Les prix sont types `?string` en PHP alors que la colonne est `DECIMAL(10,2)` en BDD.
|
||||
- **Action :**
|
||||
1. Changer le type PHP en `?float` ou utiliser `brick/money`
|
||||
2. Adapter les getters/setters
|
||||
3. Verifier la serialisation API Platform
|
||||
- **Agent :** -
|
||||
- **Notes :** Impact potentiel sur le frontend (format des nombres).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 - Utilisation du Process Component
|
||||
|
||||
> **Priorite :** BASSE - Bonne pratique
|
||||
|
||||
### 5.1 Remplacer exec() par Symfony Process
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :**
|
||||
- `src/Command/CompressPdfCommand.php` (lignes 42, 98-101)
|
||||
- `src/Service/PdfCompressorService.php` (lignes 37-41)
|
||||
- **Probleme :** Utilisation de `exec()` directe pour appeler `qpdf`.
|
||||
- **Action :**
|
||||
1. Remplacer par `Symfony\Component\Process\Process`
|
||||
2. Gerer le timeout et les erreurs proprement
|
||||
3. Tester que la compression fonctionne toujours
|
||||
- **Agent :** -
|
||||
- **Notes :** `escapeshellarg()` est deja utilise, donc pas de faille de securite immediate.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 - Tests
|
||||
|
||||
> **Priorite :** HAUTE - Indispensable avant toute refacto majeure
|
||||
|
||||
### 6.1 Mettre en place les tests unitaires
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers a creer :**
|
||||
- `tests/Unit/Util/CuidGeneratorTest.php`
|
||||
- `tests/Unit/Entity/MachineTest.php`
|
||||
- `tests/Unit/Entity/ProductTest.php`
|
||||
- `tests/Unit/Service/PdfCompressorServiceTest.php`
|
||||
- **Action :**
|
||||
1. Tester le CuidGenerator (format, unicite)
|
||||
2. Tester les entites (validation, lifecycle callbacks)
|
||||
3. Tester le PdfCompressorService
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
### 6.2 Mettre en place les tests fonctionnels (API)
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers a creer :**
|
||||
- `tests/Functional/Api/MachineTest.php`
|
||||
- `tests/Functional/Api/ProductTest.php`
|
||||
- `tests/Functional/Api/AuthenticationTest.php`
|
||||
- `tests/Functional/Api/MachineSkeletonTest.php`
|
||||
- **Action :**
|
||||
1. Configurer une base de test (SQLite ou PostgreSQL de test)
|
||||
2. Creer des fixtures de test
|
||||
3. Tester les endpoints CRUD
|
||||
4. Tester l'authentification JWT
|
||||
5. Tester les endpoints custom (skeleton, custom fields)
|
||||
- **Agent :** -
|
||||
- **Notes :** Utiliser `ApiTestCase` de API Platform.
|
||||
|
||||
### 6.3 Tests des Audit Subscribers
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers a creer :**
|
||||
- `tests/Unit/EventSubscriber/AuditSubscriberTest.php`
|
||||
- **Action :**
|
||||
1. Tester la creation de logs sur insert/update/delete
|
||||
2. Tester le format des diffs et snapshots
|
||||
3. Tester la resolution de l'acteur
|
||||
- **Agent :** -
|
||||
- **Notes :** A faire APRES la phase 2.1 (refacto des subscribers).
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 - Nett oyage et conventions
|
||||
|
||||
> **Priorite :** BASSE - Polish final
|
||||
|
||||
### 7.1 Supprimer les fichiers inutiles
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers a verifier :**
|
||||
- `frontend/` (dossier legacy ? vs `Inventory_frontend/`)
|
||||
- `src/ApiResource/` (repertoire vide)
|
||||
- Fichiers SQL a la racine (`backup_v1.0.0.sql`, `data_norm.sql`, `fullasse.sql`, `fulldata.sql`)
|
||||
- **Action :** Confirmer avec l'equipe quels fichiers sont obsoletes et les supprimer.
|
||||
- **Agent :** -
|
||||
- **Notes :** Ne pas supprimer sans validation.
|
||||
|
||||
### 7.2 Uniformiser la gestion des null
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers :** Toutes les entites dans `src/Entity/`
|
||||
- **Action :** S'assurer que les types nullable sont coherents entre PHP et la BDD (colonnes NOT NULL vs nullable).
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# FRONTEND (`Inventory_frontend/`)
|
||||
|
||||
---
|
||||
|
||||
## Phase F1 - Decoupage des mega-composants (CRITIQUE)
|
||||
|
||||
> **Priorite :** MAXIMALE - Les fichiers actuels sont inmaintenables
|
||||
|
||||
### F1.1 Decouper `machine/[id].vue` (2989 LOC → 219 LOC)
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichier :** `Inventory_frontend/app/pages/machine/[id].vue`
|
||||
- **Resultat :** Page decomposee en 2 composables + 7 composants. Orchestrateur = 219 LOC.
|
||||
- **Fichiers crees :**
|
||||
- `composables/useMachineDetailData.ts` (1404 LOC) — state + logique metier
|
||||
- `composables/useMachineSkeletonEditor.ts` (843 LOC) — logique skeleton
|
||||
- `components/machine/MachineDetailHeader.vue` (76 LOC)
|
||||
- `components/machine/MachineInfoCard.vue` (185 LOC)
|
||||
- `components/machine/MachineDocumentsCard.vue` (116 LOC)
|
||||
- `components/machine/MachineProductsCard.vue` (62 LOC)
|
||||
- `components/machine/MachineComponentsCard.vue` (53 LOC)
|
||||
- `components/machine/MachinePiecesCard.vue` (34 LOC)
|
||||
- `components/machine/MachineSkeletonSummary.vue` (199 LOC)
|
||||
- **Pattern :** Props + Events (pas de provide/inject). Composables avec injection de dependances (interface Deps).
|
||||
- **Notes :** Typecheck 0 erreurs. Lint OK.
|
||||
|
||||
### F1.2 Decouper `machines/new.vue` (1231 LOC → 196 LOC)
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichier :** `Inventory_frontend/app/pages/machines/new.vue`
|
||||
- **Resultat :** Page decomposee en 1 composable + 5 composants. Orchestrateur = 196 LOC.
|
||||
- **Fichiers crees :**
|
||||
- `composables/useMachineCreatePage.ts` (460 LOC) — state, entity lookups, options, creation
|
||||
- `components/machine/create/RequirementComponentSelector.vue` (126 LOC)
|
||||
- `components/machine/create/RequirementPieceSelector.vue` (130 LOC)
|
||||
- `components/machine/create/RequirementProductSelector.vue` (142 LOC)
|
||||
- `components/machine/create/MachineCreatePreview.vue` (205 LOC)
|
||||
- `components/machine/create/PreviewRequirementGroup.vue` (59 LOC)
|
||||
- **Pattern :** Props + Events. Composable consolide entity lookups, options, label helpers, creation.
|
||||
- **Notes :** Typecheck 0 erreurs. Lint OK. Corrige aussi un bug F1.1 (defineProps dans mauvais script block de MachineSkeletonSummary.vue).
|
||||
|
||||
### F1.3 Decouper les pages de creation/edition (Piece, Component, Product)
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichiers :**
|
||||
- `pages/component/create.vue` (1282 LOC)
|
||||
- `pages/component/[id]/edit.vue` (1629 LOC)
|
||||
- `pages/pieces/create.vue` (817 LOC)
|
||||
- `pages/pieces/[id]/edit.vue` (1327 LOC)
|
||||
- `pages/product/[id]/edit.vue` (936 LOC)
|
||||
- **Probleme :** Formulaires monolithiques avec sections multiples (infos generales, fournisseurs, documents, custom fields, etc.).
|
||||
- **Action :**
|
||||
1. Identifier les sections communes entre create/edit (factoriser)
|
||||
2. Extraire chaque section en composant reutilisable :
|
||||
- `EntityFormGeneral.vue` (nom, reference, description)
|
||||
- `EntityFormSuppliers.vue` (constructeurs)
|
||||
- `EntityFormDocuments.vue` (documents)
|
||||
- `EntityFormCustomFields.vue` (champs personnalises)
|
||||
3. Objectif par page : <400 LOC
|
||||
- **Agent :** -
|
||||
- **Notes :** Les formulaires create et edit partagent beaucoup de code. Factoriser.
|
||||
- **Sous-taches :**
|
||||
- [x] F1.3a Extraire `customFieldFormUtils.ts` (duplique dans 5 fichiers)
|
||||
- [x] F1.3b Extraire `documentDisplayUtils.ts` (duplique dans 3 pages edit)
|
||||
- [x] F1.3c Extraire `historyDisplayUtils.ts` (duplique dans 3 pages edit)
|
||||
- [x] F1.3d Rewire les 5 pages create/edit sur les modules extraits
|
||||
- [x] F1.3e Typecheck + commit F1.3 (erreurs F1.3 corrigees, 120 erreurs preexistantes documentees)
|
||||
|
||||
### F1.4 Reduire PieceItem.vue (1588 LOC) et ComponentItem.vue (1336 LOC)
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichiers :**
|
||||
- `Inventory_frontend/app/components/PieceItem.vue` (1588 → 740 LOC)
|
||||
- `Inventory_frontend/app/components/ComponentItem.vue` (1336 → 585 LOC)
|
||||
- **Probleme :** ~700 LOC de logique dupliquee entre les deux composants (champs personnalises, documents, affichage produit).
|
||||
- **Action realisee :**
|
||||
1. Extraction de la logique pure custom fields dans `shared/utils/entityCustomFieldLogic.ts` (~350 LOC)
|
||||
2. Creation de `composables/useEntityCustomFields.ts` (composable reactif, ~180 LOC)
|
||||
3. Creation de `composables/useEntityDocuments.ts` (CRUD documents + preview, ~120 LOC)
|
||||
4. Creation de `composables/useEntityProductDisplay.ts` (affichage produit, ~100 LOC)
|
||||
5. Import des helpers document depuis `shared/utils/documentDisplayUtils.ts` (existant)
|
||||
6. Rewrite des deux composants pour utiliser les modules partages
|
||||
7. Typecheck 0 erreurs, lint 0 erreurs
|
||||
- **Sous-taches :**
|
||||
- [x] F1.4a Extraire `entityCustomFieldLogic.ts` (fonctions pures)
|
||||
- [x] F1.4b Creer `useEntityCustomFields.ts` (composable reactif)
|
||||
- [x] F1.4c Creer `useEntityDocuments.ts` (composable documents)
|
||||
- [x] F1.4d Creer `useEntityProductDisplay.ts` (composable produit)
|
||||
- [x] F1.4e Rewrite ComponentItem.vue (1336 → 585 LOC, script 900 → 150 LOC)
|
||||
- [x] F1.4f Rewrite PieceItem.vue (1588 → 740 LOC, script 1100 → 255 LOC)
|
||||
- [x] F1.4g Typecheck + lint (0 erreurs)
|
||||
- **Notes :** Les templates restent volumineux (~430-480 LOC) car le contenu UI est dense. Une extraction en sous-composants (DocumentList, ProductDisplay, CustomFieldForm) serait une etape future optionnelle.
|
||||
|
||||
---
|
||||
|
||||
## Phase F2 - Elimination de la duplication frontend
|
||||
|
||||
> **Priorite :** HAUTE - DRY
|
||||
|
||||
### F2.1 Extraire `extractCollection()` dans un utilitaire partage
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichiers concernes :**
|
||||
- `composables/useSites.ts`
|
||||
- `composables/useProducts.ts`
|
||||
- `composables/usePieces.ts`
|
||||
- `composables/useComposants.ts`
|
||||
- `composables/useMachineTypesApi.js`
|
||||
- `composables/useConstructeurs.ts`
|
||||
- `composables/useDocuments.ts`
|
||||
- `composables/useMachineCreateSelections.ts`
|
||||
- `components/ComponentStructureAssignmentNode.vue`
|
||||
- `components/model-types/ManagementView.vue`
|
||||
- **Probleme :** La fonction `extractCollection()` (parsing `hydra:member` / `member` / `items` / `data` / array) etait dupliquee dans 10 fichiers.
|
||||
- **Action :**
|
||||
1. [x] Creer `shared/utils/apiHelpers.ts` avec `extractCollection<T>()` generique
|
||||
2. [x] Remplacer les 10 implementations locales par un import
|
||||
- **Agent :** -
|
||||
- **Notes :** Gere aussi `items` (utilise par ManagementView.vue). `extractRelationId()` et `normalizeRelationIds()` restent dans `shared/apiRelations.ts` (deja partages).
|
||||
|
||||
### F2.2 Fusionner les 3 composables d'historique
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichiers concernes :**
|
||||
- `composables/useComponentHistory.ts` (67 → 13 LOC, thin wrapper)
|
||||
- `composables/usePieceHistory.ts` (67 → 13 LOC, thin wrapper)
|
||||
- `composables/useProductHistory.ts` (67 → 13 LOC, thin wrapper)
|
||||
- `composables/useEntityHistory.ts` (NEW, 65 LOC, logique generique)
|
||||
- **Probleme :** 3 fichiers quasi identiques (seul le endpoint differait).
|
||||
- **Action :**
|
||||
1. [x] Creer `composables/useEntityHistory.ts` parametrable par type d'entite
|
||||
2. [x] Reecrire les 3 fichiers specifiques en wrappers backward-compatible
|
||||
- **Agent :** -
|
||||
- **Notes :** Les wrappers preservent l'API existante (types + fonction), aucun consommateur a modifier.
|
||||
|
||||
### F2.3 Factoriser les composables de types (Component/Piece/Product)
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichiers concernes :**
|
||||
- `composables/useComponentTypes.ts` (165 → 30 LOC, thin wrapper)
|
||||
- `composables/usePieceTypes.ts` (165 → 30 LOC, thin wrapper)
|
||||
- `composables/useProductTypes.ts` (160 → 28 LOC, thin wrapper)
|
||||
- `composables/useEntityTypes.ts` (NEW, 172 LOC, logique generique)
|
||||
- **Probleme :** 3 composables tres similaires pour gerer les categories/types.
|
||||
- **Action :**
|
||||
1. [x] Creer `composables/useEntityTypes.ts` generique (CRUD + singleton state par categorie)
|
||||
2. [x] Reecrire les 3 fichiers specifiques en wrappers avec renommage des champs
|
||||
- **Agent :** -
|
||||
- **Notes :** Les wrappers renomment `types` → `componentTypes`/`pieceTypes`/`productTypes`, preservent `getXxxTypes()` et `isXxxTypeLoading()`. Etat partage via `stateByCategory` map module-level.
|
||||
|
||||
---
|
||||
|
||||
## Phase F3 - Migration TypeScript
|
||||
|
||||
> **Priorite :** HAUTE - Securite du typage
|
||||
|
||||
### F3.1 Definir les types pour les reponses API
|
||||
|
||||
- **Statut :** `[x]` (partiellement — types definis dans chaque composable + `ApiResponse<T>` dans useApi.ts)
|
||||
- **Fichiers :**
|
||||
- `composables/useApi.ts` — `ApiResponse<T>` generique (success/data/error/status)
|
||||
- `composables/useMachines.ts` — `Machine` interface
|
||||
- `composables/useMachineTypesApi.ts` — `MachineType`, `MachineTypeRequirement` interfaces
|
||||
- `composables/useToast.ts` — `Toast`, `ToastType` types
|
||||
- `composables/useProfiles.ts` — `Profile` interface
|
||||
- `composables/useCustomFields.ts` — `CustomFieldValue` interface
|
||||
- **Notes :** Les types sont definis dans chaque composable (colocation). Types entite existants : `Product`, `Piece`, `Composant`, `Constructeur`, `Site`, `Document` dans leurs composables respectifs (.ts). `shared/types/inventory.ts` contient les types de structure de modele.
|
||||
|
||||
### F3.2 Convertir les composables JS en TS
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichiers convertis (7 fichiers JS → TS) :**
|
||||
- [x] `useToast.js` → `useToast.ts` (72 LOC, types: `Toast`, `ToastType`)
|
||||
- [x] `useProfiles.js` → `useProfiles.ts` (68 LOC, type: `Profile`)
|
||||
- [x] `useProfileSession.js` → `useProfileSession.ts` (85 LOC, importe `Profile`)
|
||||
- [x] `useApi.js` → `useApi.ts` (106 LOC → 120 LOC, types: `ApiResponse<T>`, `ApiCallOptions`, ajout `put()`)
|
||||
- [x] `useCustomFields.js` → `useCustomFields.ts` (105 LOC, type: `CustomFieldValue`)
|
||||
- [x] `useMachineTypesApi.js` → `useMachineTypesApi.ts` (173 → 188 LOC, types: `MachineType`, `MachineTypeRequirement`)
|
||||
- [x] `useMachines.js` → `useMachines.ts` (267 LOC, type: `Machine`, utilise `extractCollection`)
|
||||
- **Fichiers deja TS :** `useProducts.ts`, `usePieces.ts`, `useComposants.ts`, `useConstructeurs.ts`, `useSites.ts`, `useDocuments.ts`
|
||||
- **Fichiers JS restants (deprecated) :** `useComponentModels.js`, `usePieceModels.js` (stubs deprecated, a supprimer)
|
||||
- **Notes :** `ApiResponse<T = any>` par defaut `any` pour backward-compat. Les callers existants fonctionnent sans changement ; le nouveau code peut opt-in strict via `get<MyType>()`.
|
||||
|
||||
### F3.3 Eliminer les `any` restants
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichiers concernes :**
|
||||
- `components/ProductSelect.vue` — 1 `any` restant (slot template, incompressible)
|
||||
- `components/model-types/ManagementView.vue` — remplace `data?: any` → `Record<string, unknown>`, `error: any` → `error: unknown`, `item: any` → `item: unknown`
|
||||
- `components/ComponentStructureAssignmentNode.vue` — 12 casts `(definition as any).typePiece/typeProduct` elimines grace a l'extension des types
|
||||
- `components/ComponentModelStructureEditor.vue` — `Promise<any>` → `Promise<unknown>`
|
||||
- `components/model-types/ModelTypeForm.vue` — `(incoming as any).description` → cast `Record<string, unknown>`
|
||||
- `shared/types/inventory.ts` — `ComponentModelPiece.typePiece?` et `ComponentModelProduct.typeProduct?` ajoutes, 3 casts `(value as any)` supprimes
|
||||
- **Probleme :** 20+ usages de `any` type identifies.
|
||||
- **Action :** Etendre les interfaces de types pour supporter les formes alternatives de l'API. Remplacer les `any` par `unknown` ou `Record<string, unknown>` la ou possible.
|
||||
- **Agent :** Claude
|
||||
- **Notes :** ~15 casts `any` elimines. Les `Record<string, any>` restants dans ComponentModelStructureEditor sont justifies (manipulation dynamique interne de custom fields). Typecheck 0 erreurs.
|
||||
|
||||
---
|
||||
|
||||
## Phase F4 - Qualite du code frontend
|
||||
|
||||
> **Priorite :** MOYENNE
|
||||
|
||||
### F4.1 Activer les regles ESLint critiques
|
||||
|
||||
- **Statut :** `[x]` DONE
|
||||
- **Fichier :** `Inventory_frontend/eslint.config.mjs`
|
||||
- **Probleme :** Presque toutes les regles etaient desactivees (`no-console: off`, `no-unused-vars: off`, `no-explicit-any: off`).
|
||||
- **Action realisee :**
|
||||
1. [x] Active `@typescript-eslint/no-explicit-any: warn` (526 warnings — amelioration progressive)
|
||||
2. [x] Active `no-console: warn` avec `allow: ['error']` — 0 violations (deja nettoye en F4.2)
|
||||
3. [x] Active `@typescript-eslint/no-unused-vars: warn` avec ignore `^_` — 0 violations (26 corrigees)
|
||||
4. [x] Corrige les 26 violations `no-unused-vars` : imports inutilises supprimes, variables prefixees `_`, destructurations nettoyees
|
||||
- **Agent :** Claude
|
||||
- **Notes :** 16 fichiers modifies. Regles organisees par categorie (vue, console, typescript, formatting). 0 erreurs, 526 warnings `no-explicit-any` restants (warn, pas bloquant).
|
||||
|
||||
### F4.2 Nettoyer les console.log/console.error
|
||||
|
||||
- **Statut :** `[x]` (console.log supprime, console.error conserve)
|
||||
- **Fichiers modifies :** 8 fichiers (useMachineTypesApi.ts, useSites.ts, type/[id].vue, type/edit/[id].vue, TypeEditPieceRequirementsSection.vue, SearchSelect.vue, app.vue)
|
||||
- **Probleme :** 19 appels `console.log` de debug laisses dans le code de production.
|
||||
- **Action :**
|
||||
1. [x] Supprimer les 19 `console.log` de debug (normalizeRequirementList, page loading, route params, etc.)
|
||||
2. [ ] Les 72 `console.error` restants sont conserves (gestion d'erreur legitime). Migration vers un logger centralise a faire en F4.3.
|
||||
- **Agent :** Claude
|
||||
- **Notes :** 0 `console.log/warn/debug/info` restants dans le frontend.
|
||||
|
||||
### F4.3 Centraliser la gestion d'erreurs API
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichier :** `Inventory_frontend/app/composables/useApi.js` (105 LOC)
|
||||
- **Probleme :** Gestion d'erreur basique (juste un toast). Pas de retry, pas d'intercepteur, erreurs silencieuses dans certains composables.
|
||||
- **Action :**
|
||||
1. Ajouter un systeme de retry configurable (1-3 tentatives)
|
||||
2. Centraliser la gestion des erreurs HTTP (401 -> redirect login, 500 -> message explicite)
|
||||
3. Ajouter des intercepteurs request/response
|
||||
4. Uniformiser le pattern dans tous les composables
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
---
|
||||
|
||||
## Phase F5 - Reduire le fichier modelUtils.ts (1017 LOC)
|
||||
|
||||
> **Priorite :** MOYENNE
|
||||
|
||||
### F5.1 Decouper `shared/modelUtils.ts`
|
||||
|
||||
- **Statut :** `[x]`
|
||||
- **Fichier :** `Inventory_frontend/app/shared/modelUtils.ts` (1017 LOC → 37 LOC barrel)
|
||||
- **Probleme :** Fichier utilitaire monolithique de 1017 lignes regroupant toute la logique de manipulation de modeles.
|
||||
- **Action :**
|
||||
1. Identifier les groupes de fonctions (structure, custom fields, requirements, serialization)
|
||||
2. Decouper en 3 modules thematiques :
|
||||
- `shared/model/componentStructure.ts` (~590 LOC) — helpers, sanitize, hydrate, normalize, extract, format pour composants
|
||||
- `shared/model/pieceProductStructure.ts` (~155 LOC) — structure piece/produit (clone, sanitize, hydrate, format)
|
||||
- `shared/model/definitionOverrides.ts` (~50 LOC) — sanitization des overrides de definition
|
||||
3. Re-exporter depuis `shared/modelUtils.ts` (barrel) pour ne pas casser les imports
|
||||
- **Agent :** Claude
|
||||
- **Notes :** 11 fichiers consommateurs inchanges (barrel preserve la retro-compat). Typecheck 0 erreurs.
|
||||
|
||||
---
|
||||
|
||||
## Phase F6 - Tests frontend
|
||||
|
||||
> **Priorite :** HAUTE - Aucun test actuellement
|
||||
|
||||
### F6.1 Configurer Vitest
|
||||
|
||||
- **Statut :** `[x]` DONE
|
||||
- **Fichiers crees :**
|
||||
- `vitest.config.ts` — config Vitest avec happy-dom, alias `~` et `#imports`
|
||||
- `tests/__mocks__/imports.ts` — mock des auto-imports Nuxt (useRuntimeConfig, useRoute, etc.)
|
||||
- `tests/shared/inventory-types.test.ts` — 9 tests smoke (validator, empty structures)
|
||||
- **Action realisee :**
|
||||
1. [x] Installe `vitest`, `@vue/test-utils`, `happy-dom`
|
||||
2. [x] Configure Vitest avec environment happy-dom et resolution d'alias
|
||||
3. [x] Ajoute scripts `test` et `test:watch` dans `package.json`
|
||||
4. [x] Premier test suite : `componentModelStructureValidator` (9 tests, 100% pass)
|
||||
- **Agent :** Claude
|
||||
- **Notes :** `npm test` → 9 tests, 0 failures, <1s. Alias `#imports` pointe vers un mock minimal extensible.
|
||||
|
||||
### F6.2 Tests unitaires des composables
|
||||
|
||||
- **Statut :** `[x]` DONE (base)
|
||||
- **Fichiers crees :**
|
||||
- `tests/shared/apiHelpers.test.ts` — 10 tests (extractCollection, tous formats API)
|
||||
- `tests/shared/modelUtils.test.ts` — 18 tests (isPlainObject, clone, stats, format, piece/product)
|
||||
- `tests/shared/inventory-types.test.ts` — 9 tests (validator, empty structures)
|
||||
- `tests/composables/useToast.test.ts` — 9 tests (add, types, max limit, clear, singleton)
|
||||
- `tests/composables/useConfirm.test.ts` — 8 tests (open, confirm, cancel, options, singleton)
|
||||
- **Action realisee :**
|
||||
1. [x] Teste `extractCollection()` : array, hydra:member, member, items, data, null, undefined
|
||||
2. [x] Teste `useToast` : ajout, types, max 3 toasts, clearAll, removeToast, singleton
|
||||
3. [x] Teste `useConfirm` : open/close, resolve true/false, custom options, singleton state
|
||||
4. [x] Teste `modelUtils` : clone, stats, preview, isPlainObject, piece/product variants
|
||||
5. [x] Teste `componentModelStructureValidator` : valid/invalid, custom fields, subcomponents
|
||||
- **Agent :** Claude
|
||||
- **Notes :** 54 tests, 5 fichiers, 100% pass, <2s. Tests `useApi` et CRUD composables necessitent mock fetch (phase ulterieure).
|
||||
|
||||
### F6.3 Tests de composants
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Fichiers a creer :**
|
||||
- `tests/components/Pagination.test.ts`
|
||||
- `tests/components/SearchSelect.test.ts`
|
||||
- `tests/components/MachineHeader.test.ts` (apres F1.1)
|
||||
- **Action :**
|
||||
1. Tester les composants communs (Pagination, SearchSelect)
|
||||
2. Tester le rendu conditionnel et les events
|
||||
- **Agent :** -
|
||||
- **Notes :** -
|
||||
|
||||
---
|
||||
|
||||
## Phase F7 - Ameliorations UX/DX
|
||||
|
||||
> **Priorite :** BASSE - Polish
|
||||
|
||||
### F7.1 Reduire le props drilling
|
||||
|
||||
- **Statut :** `[ ]`
|
||||
- **Probleme :** Props passees sur 3+ niveaux (ex: machine data dans les sous-composants).
|
||||
- **Action :**
|
||||
1. Identifier les cas de props drilling >2 niveaux
|
||||
2. Utiliser `provide/inject` ou des composables partages
|
||||
3. Documenter le pattern choisi
|
||||
- **Agent :** -
|
||||
- **Notes :** A traiter apres F1 (decoupage des composants).
|
||||
|
||||
### F7.2 Remplacer `confirm()` natif par des modales DaisyUI
|
||||
|
||||
- **Statut :** `[x]` DONE
|
||||
- **Probleme :** Les confirmations de suppression utilisaient `window.confirm()` (UI native, non-stylee).
|
||||
- **Action realisee :**
|
||||
1. [x] Cree `composables/useConfirm.ts` — composable promise-based avec etat reactif partage
|
||||
2. [x] Cree `components/common/ConfirmModal.vue` — modale DaisyUI teleportee (backdrop blur, btn-error)
|
||||
3. [x] Monte `ConfirmModal` globalement dans `app.vue`
|
||||
4. [x] Remplace les 10 `confirm()` natifs dans 10 fichiers :
|
||||
- `constructeurs.vue`, `profiles/manage.vue`, `ManagementView.vue`
|
||||
- `product-catalog.vue`, `index.vue`, `machines/index.vue`
|
||||
- `machine-skeleton/index.vue`, `pieces-catalog.vue`, `component-catalog.vue`
|
||||
- `useSiteManagement.ts` (composable — import explicite)
|
||||
- **Agent :** Claude
|
||||
- **Notes :** API : `const { confirm } = useConfirm(); const ok = await confirm({ message: '...' })`. Auto-import Nuxt pour les SFC, import explicite pour les composables.
|
||||
|
||||
### F7.3 Nettoyer `app.vue` (861 LOC)
|
||||
|
||||
- **Statut :** `[x]` DONE
|
||||
- **Fichier :** `Inventory_frontend/app/app.vue` (861 → 49 LOC)
|
||||
- **Probleme :** Le fichier racine contenait le layout principal, la navbar (~676 LOC dupliquee mobile/desktop), et du state management.
|
||||
- **Action realisee :**
|
||||
1. Cree `composables/useNavDropdown.ts` (~65 LOC) — gestion etat dropdowns navbar
|
||||
2. Cree `components/layout/AppNavbar.vue` (~310 LOC) — navbar data-driven avec `v-for` eliminant duplication mobile/desktop
|
||||
3. `app.vue` reecrit en orchestrateur minimal (49 LOC) + converti en TypeScript
|
||||
4. Supprime 4 imports d'icones inutilises
|
||||
- **Agent :** Claude
|
||||
- **Notes :** Approche data-driven : liens et groupes definis comme tableaux types (`NavLink[]`, `NavGroup[]`), rendus par `v-for` pour mobile et desktop
|
||||
|
||||
---
|
||||
|
||||
## Ordre d'execution recommande
|
||||
|
||||
```
|
||||
=== BACKEND === === FRONTEND ===
|
||||
|
||||
Phase 6.1 (Tests unitaires) Phase F6.1 (Config Vitest)
|
||||
| |
|
||||
v v
|
||||
Phase 1 (Securite) Phase F1 (Decoupage mega-composants)
|
||||
| |
|
||||
v v
|
||||
Phase 2 (Duplication backend) Phase F2 (Duplication frontend)
|
||||
| |
|
||||
v v
|
||||
Phase 3 (Controllers) Phase F3 (Migration TypeScript)
|
||||
| |
|
||||
v v
|
||||
Phase 6.2 (Tests API) Phase F4 (Qualite code) + Phase F5 (modelUtils)
|
||||
| |
|
||||
v v
|
||||
Phase 4 (Stockage) Phase F6.2-F6.3 (Tests frontend)
|
||||
| |
|
||||
v v
|
||||
Phase 5 + Phase 7 (Nettoyage) Phase F7 (UX/DX polish)
|
||||
|
|
||||
v
|
||||
Phase 6.3 (Tests audit)
|
||||
```
|
||||
|
||||
> Les colonnes backend et frontend peuvent etre executees **en parallele** par des agents differents.
|
||||
|
||||
---
|
||||
|
||||
## Journal des modifications
|
||||
|
||||
| Date | Phase | Tache | Agent | Statut | Notes |
|
||||
| ---------- | ----- | ------------------------- | --------------- | ------- | ---------------------------------------------- |
|
||||
| 2026-02-03 | - | Creation du plan backend | Claude Opus 4.5 | Termine | Analyse initiale backend (7 phases, 17 taches) |
|
||||
| 2026-02-03 | - | Creation du plan frontend | Claude Opus 4.5 | Termine | Analyse frontend (7 phases, 22 taches) |
|
||||
| | | | | | |
|
||||
|
||||
---
|
||||
|
||||
## Commandes de verification
|
||||
|
||||
> **Contexte :** Le backend tourne dans Docker (`docker compose`), le frontend est en local.
|
||||
> Les commandes ci-dessous sont executees **depuis la racine du projet** (`/home/matthieu/dev_malio/Inventory/`).
|
||||
|
||||
### Frontend (Nuxt 3 / Vue 3 / TypeScript)
|
||||
|
||||
| Commande | Description | Quand l'utiliser |
|
||||
| -------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `npx nuxi typecheck` | Verification des types TypeScript via `vue-tsc` | Apres chaque modification de fichier `.vue` ou `.ts`. C'est la commande principale de validation. |
|
||||
| `npm run lint` | ESLint (config dans `eslint.config.mjs`) | Apres chaque modification pour verifier le style et les erreurs statiques. |
|
||||
| `npm run lint:fix` | ESLint avec auto-fix | Pour corriger automatiquement les erreurs de formatage. |
|
||||
| `npm run build` | Build de production Nuxt (inclut le typecheck) | Avant un commit pour s'assurer que tout compile. Plus lent que `typecheck` seul. |
|
||||
| `npx nuxi prepare` | Regenerer les types auto-generes (`.nuxt/`) | Si les imports auto (composables, components) ne sont pas reconnus par le typecheck. |
|
||||
|
||||
> **Toutes les commandes frontend** sont executees depuis `Inventory_frontend/` :
|
||||
>
|
||||
> ```bash
|
||||
> cd Inventory_frontend && npx nuxi typecheck
|
||||
> ```
|
||||
|
||||
> **Note sur les erreurs pre-existantes :** Il y a ~120 erreurs TypeScript pre-existantes documentees
|
||||
> (anterieures a la refacto). L'objectif est de ne pas en ajouter de nouvelles.
|
||||
> Pour verifier : comparer le nombre d'erreurs avant/apres modification.
|
||||
|
||||
### Backend (Symfony 8 / PHP 8.4)
|
||||
|
||||
| Commande | Description | Quand l'utiliser |
|
||||
| ---------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `vendor/bin/php-cs-fixer fix --dry-run --diff` | Verifie le style PHP (PSR-12 + Symfony) sans modifier | Apres chaque modification PHP. |
|
||||
| `vendor/bin/php-cs-fixer fix` | Corrige automatiquement le style PHP | Avant chaque commit. |
|
||||
| `bin/phpunit` | Lance les tests PHPUnit | Apres chaque modification backend. |
|
||||
| `php bin/console cache:clear` | Vide le cache Symfony | Si des erreurs bizarres apparaissent apres un changement de config. |
|
||||
|
||||
> **Les commandes backend** sont executees **dans le conteneur Docker** :
|
||||
>
|
||||
> ```bash
|
||||
> docker compose exec web vendor/bin/php-cs-fixer fix --dry-run --diff
|
||||
> docker compose exec web bin/phpunit
|
||||
> ```
|
||||
|
||||
### Workflow de verification (checklist par tache)
|
||||
|
||||
```
|
||||
1. Lire les fichiers concernes (AVANT toute modification)
|
||||
2. Effectuer les modifications
|
||||
3. Frontend : npx nuxi typecheck → verifier pas de nouvelles erreurs
|
||||
4. Frontend : npm run lint:fix → corriger le formatage
|
||||
5. Backend : php-cs-fixer fix → corriger le style PHP
|
||||
6. Backend : bin/phpunit → verifier la non-regression
|
||||
7. Commit si tout est OK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Regles pour les agents
|
||||
|
||||
1. **Avant de commencer une tache :**
|
||||
- Mettre le statut a `[~]` dans ce fichier
|
||||
- Inscrire son nom/ID dans la colonne "Agent"
|
||||
- Lire les fichiers concernes AVANT de modifier quoi que ce soit
|
||||
|
||||
2. **Pendant le travail :**
|
||||
- Ne modifier QUE les fichiers listes dans la tache
|
||||
- Respecter les conventions existantes (PSR-12, strict_types)
|
||||
- Ne pas introduire de nouvelles dependances sans justification
|
||||
- Lancer `php-cs-fixer` apres les modifications
|
||||
|
||||
3. **Apres avoir termine :**
|
||||
- Mettre le statut a `[x]`
|
||||
- Ajouter une entree dans le "Journal des modifications"
|
||||
- Lancer les tests existants (`make test`) pour verifier la non-regression
|
||||
- Decrire brievement les changements effectues dans "Notes"
|
||||
|
||||
4. **En cas de blocage :**
|
||||
- Mettre le statut a `[!]`
|
||||
- Documenter le blocage dans "Notes"
|
||||
- Ne PAS passer a une autre tache sans signaler le blocage
|
||||
|
||||
5. **Regles specifiques au frontend :**
|
||||
- Ecrire en TypeScript (pas de JS pour les nouveaux fichiers)
|
||||
- Pas de `any` - utiliser des types concrets
|
||||
- Pas de `console.log` - utiliser le logger ou `useToast`
|
||||
- Composants Vue : max 400 LOC par fichier
|
||||
- Utiliser les composants DaisyUI existants (pas de CSS custom)
|
||||
- Tester avec Vitest quand la config est en place
|
||||
|
||||
6. **Regles specifiques au backend :**
|
||||
- `declare(strict_types=1)` obligatoire
|
||||
- Respecter PSR-12 + regles Symfony (php-cs-fixer)
|
||||
- Pas de `exec()` direct - utiliser Symfony Process
|
||||
- Tester avec PHPUnit
|
||||
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)
|
||||
|
||||
2
TODO.md
2
TODO.md
@@ -1,2 +0,0 @@
|
||||
- Doc: ne pas oublier de mettre `make` dans la documentation.
|
||||
- Note: le probleme d'IP sous WSL, a ajouter dans la doc.
|
||||
@@ -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,9 +1,12 @@
|
||||
api_platform:
|
||||
title: Hello API Platform
|
||||
version: 1.4.0
|
||||
title: Inventory API
|
||||
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
||||
version: 1.9.1
|
||||
defaults:
|
||||
stateless: false
|
||||
cache_headers:
|
||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
||||
pagination_items_per_page: 30
|
||||
pagination_maximum_items_per_page: 200
|
||||
pagination_fetch_join_collection: true
|
||||
pagination_partial: false
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
security:
|
||||
# Login controller already calls $session->migrate(true) on login.
|
||||
# Keeping 'migrate' would regenerate the session ID on every authenticated
|
||||
# API request, which breaks concurrent requests from the SPA (race condition).
|
||||
session_fixation_strategy: none
|
||||
|
||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
||||
password_hashers:
|
||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
||||
@@ -18,44 +23,36 @@ security:
|
||||
pattern: ^/(_profiler|_wdt|assets|build)/
|
||||
security: false
|
||||
|
||||
login:
|
||||
pattern: ^/api/login_check
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
json_login:
|
||||
check_path: /api/login_check
|
||||
username_path: email
|
||||
password_path: password
|
||||
success_handler: lexik_jwt_authentication.handler.authentication_success
|
||||
failure_handler: lexik_jwt_authentication.handler.authentication_failure
|
||||
|
||||
session_profile:
|
||||
pattern: ^/api/session
|
||||
stateless: false
|
||||
|
||||
session_api:
|
||||
pattern: ^/api/(sites|machines|documents|profiles)
|
||||
stateless: false
|
||||
session_public:
|
||||
pattern: ^/api/session/profiles?$
|
||||
security: false
|
||||
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: false
|
||||
custom_authenticators:
|
||||
- App\Security\SessionProfileAuthenticator
|
||||
|
||||
main:
|
||||
lazy: true
|
||||
provider: app_user_provider
|
||||
|
||||
role_hierarchy:
|
||||
ROLE_ADMIN: ROLE_GESTIONNAIRE
|
||||
ROLE_GESTIONNAIRE: ROLE_VIEWER
|
||||
ROLE_VIEWER: ROLE_USER
|
||||
|
||||
# Note: Only the *first* matching rule is applied
|
||||
access_control:
|
||||
- { path: ^/api/session/profile, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/session/profiles, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/session/profile$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/session/profiles, roles: PUBLIC_ACCESS, methods: [GET] }
|
||||
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/test, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
|
||||
- { path: ^/api, roles: ROLE_VIEWER }
|
||||
|
||||
when@test:
|
||||
security:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Migration DB (manuel)
|
||||
|
||||
Ce guide explique comment importer un dump SQL venant de pgAdmin dans la base Docker.
|
||||
|
||||
## 1) Export pgAdmin
|
||||
|
||||
Dans pgAdmin:
|
||||
|
||||
- Format: Plain
|
||||
- Options: Use INSERT commands + Use column inserts
|
||||
- Fichier: `data.sql`
|
||||
|
||||
## 2) Normaliser le dump
|
||||
|
||||
Convertit les colonnes camelCase en lowercase compact.
|
||||
|
||||
```bash
|
||||
python3 scripts/normalize-dump.py data.sql data_norm.sql --lower
|
||||
```
|
||||
|
||||
## 3) Importer dans la base Docker
|
||||
|
||||
Utilise `session_replication_role` pour eviter les erreurs de contraintes circulaires.
|
||||
|
||||
```bash
|
||||
docker compose --env-file docker/.env.docker.local exec -T db psql -U root -d inventory -v ON_ERROR_STOP=1 -c "SET session_replication_role = replica;"
|
||||
docker compose --env-file docker/.env.docker.local exec -T db psql -U root -d inventory -v ON_ERROR_STOP=1 < data_norm.sql
|
||||
docker compose --env-file docker/.env.docker.local exec -T db psql -U root -d inventory -v ON_ERROR_STOP=1 -c "SET session_replication_role = DEFAULT;"
|
||||
```
|
||||
|
||||
## 4) Verifier
|
||||
|
||||
```bash
|
||||
docker compose --env-file docker/.env.docker.local exec -T db psql -U $POSTGRES_USER -d $POSTGRES_DB -c "\\dt"
|
||||
```
|
||||
51
migrations/Version20260302103003.php
Normal file
51
migrations/Version20260302103003.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260302103003 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create comments table + make piece reference unique instead of name';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Comments table (IF NOT EXISTS in case first attempt partially succeeded)
|
||||
$this->addSql('CREATE TABLE IF NOT EXISTS comments (id VARCHAR(36) NOT NULL, content TEXT NOT NULL, entity_type VARCHAR(50) NOT NULL, entity_id VARCHAR(36) NOT NULL, entity_name VARCHAR(255) DEFAULT NULL, author_id VARCHAR(36) NOT NULL, author_name VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, resolved_by_id VARCHAR(36) DEFAULT NULL, resolved_by_name VARCHAR(255) DEFAULT NULL, resolved_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comment_entity_status ON comments (entity_type, entity_id, status)');
|
||||
$this->addSql('COMMENT ON COLUMN comments.resolved_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN comments.created_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
$this->addSql('COMMENT ON COLUMN comments.updated_at IS \'(DC2Type:datetime_immutable)\'');
|
||||
|
||||
// Piece: remove unique constraint on name (it's a constraint, not just an index)
|
||||
$this->addSql('ALTER TABLE pieces DROP CONSTRAINT IF EXISTS uniq_b92d74725e237e06');
|
||||
|
||||
// Deduplicate piece references before adding unique constraint
|
||||
$this->addSql("
|
||||
UPDATE pieces p
|
||||
SET reference = p.reference || '-' || LEFT(p.id, 6)
|
||||
FROM (
|
||||
SELECT id, reference,
|
||||
ROW_NUMBER() OVER (PARTITION BY reference ORDER BY createdat) AS rn
|
||||
FROM pieces
|
||||
WHERE reference IS NOT NULL AND reference != ''
|
||||
) dup
|
||||
WHERE p.id = dup.id AND dup.rn > 1
|
||||
");
|
||||
|
||||
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_pieces_reference ON pieces (reference)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS comments');
|
||||
$this->addSql('DROP INDEX IF EXISTS uniq_pieces_reference');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_b92d74725e237e06 ON pieces (name)');
|
||||
}
|
||||
}
|
||||
28
migrations/Version20260302120000.php
Normal file
28
migrations/Version20260302120000.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260302120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add description column to pieces and composants tables';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS description TEXT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE composants ADD COLUMN IF NOT EXISTS description TEXT DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS description');
|
||||
$this->addSql('ALTER TABLE composants DROP COLUMN IF EXISTS description');
|
||||
}
|
||||
}
|
||||
158
migrations/Version20260304120000.php
Normal file
158
migrations/Version20260304120000.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260304120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Remove TypeMachine skeleton system, link custom fields directly to machines';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1. Drop requirement FK columns on link tables
|
||||
$this->addSql('ALTER TABLE machine_component_links DROP COLUMN IF EXISTS typemachinecomponentrequirementid');
|
||||
$this->addSql('ALTER TABLE machine_piece_links DROP COLUMN IF EXISTS typemachinepiecerequirementid');
|
||||
$this->addSql('ALTER TABLE machine_product_links DROP COLUMN IF EXISTS typemachineproductrequirementid');
|
||||
|
||||
// 2. Add machineid column to custom_fields (new direct FK to machines)
|
||||
$this->addSql('ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machineid VARCHAR(36) DEFAULT NULL');
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_custom_fields_machine' AND table_name = 'custom_fields'
|
||||
) THEN
|
||||
ALTER TABLE custom_fields ADD CONSTRAINT fk_custom_fields_machine
|
||||
FOREIGN KEY (machineid) REFERENCES machines(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
|
||||
// 3. Enable pgcrypto for gen_random_bytes (needed for CUID generation)
|
||||
$this->addSql('CREATE EXTENSION IF NOT EXISTS pgcrypto');
|
||||
|
||||
// 4. Migrate existing custom fields: copy from TypeMachine to each linked Machine
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO custom_fields (id, name, type, required, defaultvalue, options, orderindex, machineid, createdat, updatedat)
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
cf.name, cf.type, cf.required, cf.defaultvalue, cf.options, cf.orderindex,
|
||||
m.id,
|
||||
NOW(), NOW()
|
||||
FROM custom_fields cf
|
||||
JOIN machines m ON m.typemachineid = cf.typemachineid
|
||||
WHERE cf.typemachineid IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM custom_fields existing
|
||||
WHERE existing.machineid = m.id AND existing.name = cf.name
|
||||
)
|
||||
SQL);
|
||||
|
||||
// 4. Delete original TypeMachine-linked custom fields (now migrated)
|
||||
$this->addSql('DELETE FROM custom_fields WHERE typemachineid IS NOT NULL');
|
||||
|
||||
// 5. Drop typemachineid column from custom_fields
|
||||
$this->addSql('ALTER TABLE custom_fields DROP COLUMN IF EXISTS typemachineid');
|
||||
|
||||
// 6. Drop typemachineid column from machines
|
||||
$this->addSql('ALTER TABLE machines DROP COLUMN IF EXISTS typemachineid');
|
||||
|
||||
// 7. Drop requirement tables (order matters: these reference type_machines)
|
||||
$this->addSql('DROP TABLE IF EXISTS type_machine_component_requirements');
|
||||
$this->addSql('DROP TABLE IF EXISTS type_machine_piece_requirements');
|
||||
$this->addSql('DROP TABLE IF EXISTS type_machine_product_requirements');
|
||||
|
||||
// 8. Drop type_machines table
|
||||
$this->addSql('DROP TABLE IF EXISTS type_machines');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Recreate type_machines table
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS type_machines (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
description TEXT DEFAULT NULL,
|
||||
category VARCHAR(255) DEFAULT NULL,
|
||||
maintenancefrequency VARCHAR(255) DEFAULT NULL,
|
||||
components JSON DEFAULT NULL,
|
||||
criticalparts JSON DEFAULT NULL,
|
||||
machinepieces JSON DEFAULT NULL,
|
||||
specifications JSON DEFAULT NULL,
|
||||
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
// Recreate requirement tables
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS type_machine_component_requirements (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
typemachineid VARCHAR(36) NOT NULL REFERENCES type_machines(id) ON DELETE CASCADE,
|
||||
typecomposantid VARCHAR(36) NOT NULL REFERENCES model_types(id),
|
||||
label VARCHAR(255) DEFAULT NULL,
|
||||
mincount INTEGER DEFAULT 1,
|
||||
maxcount INTEGER DEFAULT NULL,
|
||||
required BOOLEAN DEFAULT true,
|
||||
allownewmodels BOOLEAN DEFAULT true,
|
||||
orderindex INTEGER DEFAULT 0,
|
||||
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS type_machine_piece_requirements (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
typemachineid VARCHAR(36) NOT NULL REFERENCES type_machines(id) ON DELETE CASCADE,
|
||||
typepieceid VARCHAR(36) NOT NULL REFERENCES model_types(id),
|
||||
label VARCHAR(255) DEFAULT NULL,
|
||||
mincount INTEGER DEFAULT 0,
|
||||
maxcount INTEGER DEFAULT NULL,
|
||||
required BOOLEAN DEFAULT false,
|
||||
allownewmodels BOOLEAN DEFAULT true,
|
||||
orderindex INTEGER DEFAULT 0,
|
||||
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS type_machine_product_requirements (
|
||||
id VARCHAR(36) NOT NULL PRIMARY KEY,
|
||||
typemachineid VARCHAR(36) NOT NULL REFERENCES type_machines(id) ON DELETE CASCADE,
|
||||
typeproductid VARCHAR(36) NOT NULL REFERENCES model_types(id),
|
||||
label VARCHAR(255) DEFAULT NULL,
|
||||
mincount INTEGER DEFAULT 0,
|
||||
maxcount INTEGER DEFAULT NULL,
|
||||
required BOOLEAN DEFAULT false,
|
||||
allownewmodels BOOLEAN DEFAULT true,
|
||||
orderindex INTEGER DEFAULT 0,
|
||||
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
|
||||
)
|
||||
SQL);
|
||||
|
||||
// Re-add typemachineid to machines
|
||||
$this->addSql('ALTER TABLE machines ADD COLUMN IF NOT EXISTS typemachineid VARCHAR(36) DEFAULT NULL');
|
||||
|
||||
// Re-add typemachineid to custom_fields
|
||||
$this->addSql('ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS typemachineid VARCHAR(36) DEFAULT NULL');
|
||||
|
||||
// Re-add requirement FK columns to link tables
|
||||
$this->addSql('ALTER TABLE machine_component_links ADD COLUMN IF NOT EXISTS typemachinecomponentrequirementid VARCHAR(36) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE machine_piece_links ADD COLUMN IF NOT EXISTS typemachinepiecerequirementid VARCHAR(36) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE machine_product_links ADD COLUMN IF NOT EXISTS typemachineproductrequirementid VARCHAR(36) DEFAULT NULL');
|
||||
|
||||
// Drop machine FK on custom_fields
|
||||
$this->addSql('ALTER TABLE custom_fields DROP COLUMN IF EXISTS machineid');
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Entity\Document;
|
||||
use App\Repository\DocumentRepository;
|
||||
use App\Service\DocumentStorageService;
|
||||
use App\Service\PdfCompressorService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
@@ -13,15 +16,20 @@ use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function count;
|
||||
use function strlen;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:compress-pdf',
|
||||
description: 'Compress all PDF documents stored in database without quality loss',
|
||||
description: 'Compress all PDF documents without quality loss',
|
||||
)]
|
||||
class CompressPdfCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DocumentRepository $documentRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly PdfCompressorService $pdfCompressor,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -61,87 +69,13 @@ class CompressPdfCommand extends Command
|
||||
$compressed = 0;
|
||||
|
||||
foreach ($documents as $document) {
|
||||
$base64Data = $document->getPath();
|
||||
$path = $document->getPath();
|
||||
|
||||
// Remove data URI prefix if present
|
||||
if (str_contains($base64Data, ',')) {
|
||||
$base64Data = explode(',', $base64Data, 2)[1];
|
||||
}
|
||||
|
||||
$pdfContent = base64_decode($base64Data, true);
|
||||
if (false === $pdfContent) {
|
||||
$io->warning(sprintf('Failed to decode document: %s', $document->getName()));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$originalSize = strlen($pdfContent);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->text(sprintf(
|
||||
' [DRY-RUN] Would compress: %s (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create temp files
|
||||
$tempInput = tempnam(sys_get_temp_dir(), 'pdf_in_');
|
||||
$tempOutput = tempnam(sys_get_temp_dir(), 'pdf_out_');
|
||||
|
||||
file_put_contents($tempInput, $pdfContent);
|
||||
|
||||
// Compress with qpdf (lossless)
|
||||
$command = sprintf(
|
||||
'qpdf --linearize --object-streams=generate %s %s 2>&1',
|
||||
escapeshellarg($tempInput),
|
||||
escapeshellarg($tempOutput)
|
||||
);
|
||||
|
||||
exec($command, $cmdOutput, $returnCode);
|
||||
|
||||
if (0 !== $returnCode || !file_exists($tempOutput)) {
|
||||
$io->warning(sprintf('Failed to compress: %s', $document->getName()));
|
||||
@unlink($tempInput);
|
||||
@unlink($tempOutput);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$compressedContent = file_get_contents($tempOutput);
|
||||
$compressedSize = strlen($compressedContent);
|
||||
|
||||
// Only update if we actually saved space
|
||||
if ($compressedSize < $originalSize) {
|
||||
$saved = $originalSize - $compressedSize;
|
||||
$totalSaved += $saved;
|
||||
++$compressed;
|
||||
|
||||
// Rebuild base64 with data URI prefix
|
||||
$newBase64 = 'data:application/pdf;base64,'.base64_encode($compressedContent);
|
||||
$document->setPath($newBase64);
|
||||
$document->setSize($compressedSize);
|
||||
|
||||
$io->text(sprintf(
|
||||
' ✓ %s: %s → %s (-%s, -%.1f%%)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize),
|
||||
$this->formatBytes($compressedSize),
|
||||
$this->formatBytes($saved),
|
||||
($saved / $originalSize) * 100
|
||||
));
|
||||
if ($this->storageService->isBase64DataUri($path)) {
|
||||
$this->compressBase64Document($document, $path, $dryRun, $io, $totalSaved, $compressed);
|
||||
} else {
|
||||
$io->text(sprintf(
|
||||
' - %s: Already optimal (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
$this->compressFileDocument($document, $path, $dryRun, $io, $totalSaved, $compressed);
|
||||
}
|
||||
|
||||
@unlink($tempInput);
|
||||
@unlink($tempOutput);
|
||||
}
|
||||
|
||||
if (!$dryRun && $compressed > 0) {
|
||||
@@ -161,6 +95,115 @@ class CompressPdfCommand extends Command
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function compressBase64Document(
|
||||
Document $document,
|
||||
string $path,
|
||||
bool $dryRun,
|
||||
SymfonyStyle $io,
|
||||
int &$totalSaved,
|
||||
int &$compressed,
|
||||
): void {
|
||||
$base64Data = $path;
|
||||
if (str_contains($base64Data, ',')) {
|
||||
$base64Data = explode(',', $base64Data, 2)[1];
|
||||
}
|
||||
|
||||
$pdfContent = base64_decode($base64Data, true);
|
||||
if (false === $pdfContent) {
|
||||
$io->warning(sprintf('Failed to decode document: %s', $document->getName()));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$originalSize = strlen($pdfContent);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->text(sprintf(
|
||||
' [DRY-RUN] Would compress (base64): %s (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->pdfCompressor->compressBase64Pdf($path);
|
||||
if (null !== $result) {
|
||||
$document->setPath($result['path']);
|
||||
$document->setSize($result['size']);
|
||||
$totalSaved += $result['saved'];
|
||||
++$compressed;
|
||||
|
||||
$io->text(sprintf(
|
||||
' OK %s: %s → %s (-%s, -%.1f%%)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($result['originalSize']),
|
||||
$this->formatBytes($result['size']),
|
||||
$this->formatBytes($result['saved']),
|
||||
($result['saved'] / $result['originalSize']) * 100
|
||||
));
|
||||
} else {
|
||||
$io->text(sprintf(
|
||||
' - %s: Already optimal (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function compressFileDocument(
|
||||
Document $document,
|
||||
string $path,
|
||||
bool $dryRun,
|
||||
SymfonyStyle $io,
|
||||
int &$totalSaved,
|
||||
int &$compressed,
|
||||
): void {
|
||||
$absolutePath = $this->storageService->getAbsolutePath($path);
|
||||
if (!file_exists($absolutePath)) {
|
||||
$io->warning(sprintf('File not found: %s (%s)', $document->getName(), $path));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$originalSize = filesize($absolutePath);
|
||||
if (false === $originalSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$io->text(sprintf(
|
||||
' [DRY-RUN] Would compress (file): %s (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $this->pdfCompressor->compressFile($absolutePath);
|
||||
if (null !== $result) {
|
||||
$document->setSize($result['size']);
|
||||
$totalSaved += $result['saved'];
|
||||
++$compressed;
|
||||
|
||||
$io->text(sprintf(
|
||||
' OK %s: %s → %s (-%s, -%.1f%%)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($result['originalSize']),
|
||||
$this->formatBytes($result['size']),
|
||||
$this->formatBytes($result['saved']),
|
||||
($result['saved'] / $result['originalSize']) * 100
|
||||
));
|
||||
} else {
|
||||
$io->text(sprintf(
|
||||
' - %s: Already optimal (%s)',
|
||||
$document->getName(),
|
||||
$this->formatBytes($originalSize)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
85
src/Command/InitProfilePasswordsCommand.php
Normal file
85
src/Command/InitProfilePasswordsCommand.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\ProfileRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
use function count;
|
||||
use function in_array;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:init-profile-passwords',
|
||||
description: 'Initialize all profile passwords to first letter of firstName + "123"',
|
||||
)]
|
||||
class InitProfilePasswordsCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$all = $this->profiles->findAll();
|
||||
|
||||
if (0 === count($all)) {
|
||||
$io->warning('Aucun profil trouvé.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Promote first profile to ROLE_ADMIN if none exists
|
||||
$hasAdmin = false;
|
||||
foreach ($all as $profile) {
|
||||
if (in_array('ROLE_ADMIN', $profile->getRoles(), true)) {
|
||||
$hasAdmin = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$isFirst = true;
|
||||
$count = 0;
|
||||
foreach ($all as $profile) {
|
||||
// Set password: first letter of firstName + "123"
|
||||
$firstLetter = mb_strtoupper(mb_substr($profile->getFirstName(), 0, 1));
|
||||
$plain = $firstLetter.'123';
|
||||
$hashed = $this->passwordHasher->hashPassword($profile, $plain);
|
||||
$profile->setPassword($hashed);
|
||||
|
||||
// Set roles: first profile → ADMIN, others → VIEWER (minimum to use the app)
|
||||
if (!$hasAdmin && $isFirst) {
|
||||
$profile->setRoles(['ROLE_ADMIN']);
|
||||
$io->writeln(sprintf(' %s %s → mdp: %s — ROLE_ADMIN', $profile->getFirstName(), $profile->getLastName(), $plain));
|
||||
$isFirst = false;
|
||||
} elseif (in_array('ROLE_USER', $profile->getRoles(), true) && !in_array('ROLE_VIEWER', $profile->getRoles(), true) && !in_array('ROLE_GESTIONNAIRE', $profile->getRoles(), true) && !in_array('ROLE_ADMIN', $profile->getRoles(), true)) {
|
||||
$profile->setRoles(['ROLE_VIEWER']);
|
||||
$io->writeln(sprintf(' %s %s → mdp: %s — ROLE_VIEWER', $profile->getFirstName(), $profile->getLastName(), $plain));
|
||||
} else {
|
||||
$io->writeln(sprintf(' %s %s → mdp: %s — %s', $profile->getFirstName(), $profile->getLastName(), $plain, implode(', ', $profile->getRoles())));
|
||||
}
|
||||
|
||||
++$count;
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
$io->success(sprintf('%d mot(s) de passe initialisé(s).', $count));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
218
src/Command/MigrateDocumentsToFilesystemCommand.php
Normal file
218
src/Command/MigrateDocumentsToFilesystemCommand.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Repository\DocumentRepository;
|
||||
use App\Service\DocumentStorageService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Throwable;
|
||||
|
||||
use function count;
|
||||
use function strlen;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:migrate-documents-to-filesystem',
|
||||
description: 'Migrate document storage from Base64 in DB to filesystem',
|
||||
)]
|
||||
class MigrateDocumentsToFilesystemCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DocumentRepository $documentRepository,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be migrated without making changes')
|
||||
->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Number of documents to process before flushing', '50')
|
||||
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Max documents to migrate (for testing)', '0')
|
||||
;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = $input->getOption('dry-run');
|
||||
$batchSize = (int) $input->getOption('batch-size');
|
||||
$limit = (int) $input->getOption('limit');
|
||||
|
||||
$io->title('Document Storage Migration: Base64 → Filesystem');
|
||||
|
||||
// Verify storage directory is writable
|
||||
$storageDir = $this->storageService->getStorageDir();
|
||||
if (!$dryRun) {
|
||||
if (!is_dir($storageDir)) {
|
||||
mkdir($storageDir, 0o775, true);
|
||||
}
|
||||
if (!is_writable($storageDir)) {
|
||||
$io->error("Storage directory is not writable: {$storageDir}");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$io->text("Storage directory: {$storageDir}");
|
||||
}
|
||||
|
||||
// Step 1: fetch only IDs of Base64 documents (no heavy path column loaded)
|
||||
$conn = $this->em->getConnection();
|
||||
$ids = $conn->fetchFirstColumn("SELECT id FROM documents WHERE path LIKE 'data:%'");
|
||||
$total = count($ids);
|
||||
$migrated = 0;
|
||||
$skipped = 0;
|
||||
$errors = 0;
|
||||
$totalBytes = 0;
|
||||
|
||||
$io->text(sprintf('Found %d documents with Base64 data to migrate', $total));
|
||||
|
||||
if (0 === $total) {
|
||||
$io->success('Nothing to migrate — all documents are already file-based.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// Step 2: process one document at a time to avoid memory exhaustion
|
||||
foreach ($ids as $index => $docId) {
|
||||
if ($limit > 0 && $migrated >= $limit) {
|
||||
$io->text("Reached limit of {$limit} documents.");
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Fetch single row with raw SQL to keep memory flat
|
||||
$row = $conn->fetchAssociative(
|
||||
'SELECT id, name, filename, path, mimetype, size FROM documents WHERE id = ?',
|
||||
[$docId]
|
||||
);
|
||||
|
||||
if (!$row) {
|
||||
++$skipped;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $row['path'];
|
||||
if (!$this->storageService->isBase64DataUri($path)) {
|
||||
++$skipped;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$docName = $row['name'] ?: $row['filename'];
|
||||
$filename = $row['filename'] ?: $row['name'];
|
||||
$mimeType = $row['mimetype'] ?? 'application/octet-stream';
|
||||
|
||||
// Extract binary content from data URI
|
||||
$parts = explode(',', $path, 2);
|
||||
$base64 = $parts[1] ?? '';
|
||||
$content = base64_decode($base64, true);
|
||||
|
||||
// Free the raw row immediately
|
||||
unset($row, $path, $base64, $parts);
|
||||
|
||||
if (false === $content || '' === $content) {
|
||||
$io->warning(sprintf('[%d/%d] Cannot decode: %s (id: %s)', $index + 1, $total, $docName, $docId));
|
||||
++$errors;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileSize = strlen($content);
|
||||
$extension = $this->storageService->extensionFromFilename(
|
||||
$filename ?: ('file.'.$this->storageService->extensionFromMimeType($mimeType))
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->text(sprintf(
|
||||
' [DRY-RUN] Would migrate: %s (%s)',
|
||||
$docName,
|
||||
$this->formatBytes($fileSize)
|
||||
));
|
||||
++$migrated;
|
||||
$totalBytes += $fileSize;
|
||||
unset($content);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$relativePath = $this->storageService->store($content, $docId, $extension);
|
||||
unset($content);
|
||||
|
||||
// Update DB directly — avoid loading entity with huge path
|
||||
$conn->executeStatement(
|
||||
'UPDATE documents SET path = ?, size = ? WHERE id = ?',
|
||||
[$relativePath, $fileSize, $docId]
|
||||
);
|
||||
|
||||
++$migrated;
|
||||
$totalBytes += $fileSize;
|
||||
|
||||
$io->text(sprintf(
|
||||
' [OK] %s → %s (%s)',
|
||||
$docName,
|
||||
$relativePath,
|
||||
$this->formatBytes($fileSize)
|
||||
));
|
||||
} catch (Throwable $e) {
|
||||
unset($content);
|
||||
$io->error(sprintf(
|
||||
' [FAIL] %s: %s',
|
||||
$docName,
|
||||
$e->getMessage()
|
||||
));
|
||||
++$errors;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (0 === $migrated % $batchSize) {
|
||||
$io->text(sprintf(' ... %d migrated so far', $migrated));
|
||||
}
|
||||
}
|
||||
|
||||
$io->newLine();
|
||||
$io->table(
|
||||
['Metric', 'Count'],
|
||||
[
|
||||
['Total documents', (string) $total],
|
||||
['Migrated', (string) $migrated],
|
||||
['Skipped (already file-based)', (string) $skipped],
|
||||
['Errors', (string) $errors],
|
||||
['Total bytes written', $this->formatBytes($totalBytes)],
|
||||
]
|
||||
);
|
||||
|
||||
if ($dryRun) {
|
||||
$io->info('Dry run completed. No changes were made.');
|
||||
} elseif ($errors > 0) {
|
||||
$io->warning(sprintf('Migration completed with %d errors.', $errors));
|
||||
} else {
|
||||
$io->success('Migration completed successfully.');
|
||||
}
|
||||
|
||||
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$i = 0;
|
||||
while ($bytes >= 1024 && $i < count($units) - 1) {
|
||||
$bytes /= 1024;
|
||||
++$i;
|
||||
}
|
||||
|
||||
return round($bytes, 2).' '.$units[$i];
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,12 @@ namespace App\Controller;
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class ActivityLogController
|
||||
final class ActivityLogController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
@@ -21,6 +22,8 @@ final class ActivityLogController
|
||||
#[Route('/api/activity-logs', name: 'api_activity_logs', methods: ['GET'])]
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$page = max(1, $request->query->getInt('page', 1));
|
||||
$itemsPerPage = min(100, max(1, $request->query->getInt('itemsPerPage', 30)));
|
||||
|
||||
|
||||
193
src/Controller/AdminProfileController.php
Normal file
193
src/Controller/AdminProfileController.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Profile;
|
||||
use App\Repository\ProfileRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
use function count;
|
||||
use function in_array;
|
||||
|
||||
#[Route('/api/admin/profiles')]
|
||||
final class AdminProfileController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {}
|
||||
|
||||
#[Route('', name: 'admin_profiles_list', methods: ['GET'])]
|
||||
public function list(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
$items = $this->profiles->findBy([], ['firstName' => 'ASC']);
|
||||
|
||||
return new JsonResponse(array_map([$this, 'serializeProfile'], $items));
|
||||
}
|
||||
|
||||
#[Route('', name: 'admin_profiles_create', methods: ['POST'])]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
$payload = $request->toArray();
|
||||
$firstName = trim((string) ($payload['firstName'] ?? ''));
|
||||
$lastName = trim((string) ($payload['lastName'] ?? ''));
|
||||
|
||||
if ('' === $firstName || '' === $lastName) {
|
||||
return new JsonResponse(['message' => 'firstName et lastName sont requis.'], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$email = trim((string) ($payload['email'] ?? ''));
|
||||
$password = $payload['password'] ?? null;
|
||||
$role = $payload['role'] ?? 'ROLE_VIEWER';
|
||||
|
||||
$allowedRoles = ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'];
|
||||
if (!in_array($role, $allowedRoles, true)) {
|
||||
return new JsonResponse(['message' => 'Role invalide.'], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$profile = new Profile();
|
||||
$profile->setFirstName($firstName);
|
||||
$profile->setLastName($lastName);
|
||||
$profile->setIsActive(true);
|
||||
$profile->setRoles([$role]);
|
||||
|
||||
if ('' !== $email) {
|
||||
$profile->setEmail($email);
|
||||
}
|
||||
|
||||
if (null !== $password && '' !== $password) {
|
||||
$profile->setPassword(
|
||||
$this->passwordHasher->hashPassword($profile, $password)
|
||||
);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($profile);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse($this->serializeProfile($profile), JsonResponse::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/{id}/role', name: 'admin_profiles_update_role', methods: ['PUT'])]
|
||||
public function updateRole(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
$profile = $this->profiles->find($id);
|
||||
if (!$profile) {
|
||||
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $request->toArray();
|
||||
$role = $payload['role'] ?? null;
|
||||
|
||||
$allowedRoles = ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'];
|
||||
if (!$role || !in_array($role, $allowedRoles, true)) {
|
||||
return new JsonResponse(['message' => 'Role invalide.'], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Prevent removing the last admin
|
||||
if (in_array('ROLE_ADMIN', $profile->getRoles(), true) && 'ROLE_ADMIN' !== $role) {
|
||||
$adminCount = $this->countAdmins();
|
||||
if ($adminCount <= 1) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Impossible de retirer le dernier administrateur.'],
|
||||
JsonResponse::HTTP_CONFLICT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$profile->setRoles([$role]);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse($this->serializeProfile($profile));
|
||||
}
|
||||
|
||||
#[Route('/{id}/password', name: 'admin_profiles_update_password', methods: ['PUT'])]
|
||||
public function updatePassword(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
$profile = $this->profiles->find($id);
|
||||
if (!$profile) {
|
||||
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$payload = $request->toArray();
|
||||
$password = $payload['password'] ?? '';
|
||||
|
||||
if ('' === $password) {
|
||||
return new JsonResponse(['message' => 'Le mot de passe est requis.'], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$profile->setPassword(
|
||||
$this->passwordHasher->hashPassword($profile, $password)
|
||||
);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse($this->serializeProfile($profile));
|
||||
}
|
||||
|
||||
#[Route('/{id}/deactivate', name: 'admin_profiles_deactivate', methods: ['PUT'])]
|
||||
public function deactivate(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
$profile = $this->profiles->find($id);
|
||||
if (!$profile) {
|
||||
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
// Prevent deactivating the last admin
|
||||
if (in_array('ROLE_ADMIN', $profile->getRoles(), true)) {
|
||||
$adminCount = $this->countAdmins();
|
||||
if ($adminCount <= 1) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Impossible de desactiver le dernier administrateur.'],
|
||||
JsonResponse::HTTP_CONFLICT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$profile->setIsActive(false);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse($this->serializeProfile($profile));
|
||||
}
|
||||
|
||||
private function serializeProfile(Profile $profile): array
|
||||
{
|
||||
return [
|
||||
'id' => $profile->getId(),
|
||||
'firstName' => $profile->getFirstName(),
|
||||
'lastName' => $profile->getLastName(),
|
||||
'email' => $profile->getEmail(),
|
||||
'isActive' => $profile->isActive(),
|
||||
'hasPassword' => null !== $profile->getPassword() && '' !== $profile->getPassword(),
|
||||
'roles' => $profile->getRoles(),
|
||||
'createdAt' => $profile->getCreatedAt()->format('c'),
|
||||
'updatedAt' => $profile->getUpdatedAt()->format('c'),
|
||||
];
|
||||
}
|
||||
|
||||
private function countAdmins(): int
|
||||
{
|
||||
$all = $this->profiles->findBy(['isActive' => true]);
|
||||
|
||||
return count(array_filter(
|
||||
$all,
|
||||
static fn (Profile $p) => in_array('ROLE_ADMIN', $p->getRoles(), true)
|
||||
));
|
||||
}
|
||||
}
|
||||
145
src/Controller/CommentController.php
Normal file
145
src/Controller/CommentController.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/comments')]
|
||||
final class CommentController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
|
||||
#[Route('', name: 'api_comments_create', methods: ['POST'])]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$session = $request->getSession();
|
||||
$profileId = $session->get('profileId');
|
||||
if (!$profileId) {
|
||||
return $this->json(['message' => 'Aucun profil actif.'], 401);
|
||||
}
|
||||
|
||||
$profile = $this->profiles->find($profileId);
|
||||
if (!$profile) {
|
||||
return $this->json(['message' => 'Profil introuvable.'], 401);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['message' => 'Payload JSON invalide.'], 400);
|
||||
}
|
||||
|
||||
$content = trim((string) ($payload['content'] ?? ''));
|
||||
$entityType = trim((string) ($payload['entityType'] ?? ''));
|
||||
$entityId = trim((string) ($payload['entityId'] ?? ''));
|
||||
$entityName = isset($payload['entityName']) ? trim((string) $payload['entityName']) : null;
|
||||
|
||||
if ('' === $content) {
|
||||
return $this->json(['message' => 'Le contenu est requis.'], 400);
|
||||
}
|
||||
|
||||
$allowedTypes = ['machine', 'piece', 'composant', 'product', 'piece_category', 'component_category', 'product_category', 'machine_skeleton'];
|
||||
if (!in_array($entityType, $allowedTypes, true)) {
|
||||
return $this->json(['message' => 'Type d\'entité invalide.'], 400);
|
||||
}
|
||||
|
||||
if ('' === $entityId) {
|
||||
return $this->json(['message' => 'L\'identifiant de l\'entité est requis.'], 400);
|
||||
}
|
||||
|
||||
$authorName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ('' === $authorName) {
|
||||
$authorName = $profile->getEmail() ?? 'Inconnu';
|
||||
}
|
||||
|
||||
$comment = new Comment();
|
||||
$comment->setContent($content);
|
||||
$comment->setEntityType($entityType);
|
||||
$comment->setEntityId($entityId);
|
||||
$comment->setEntityName($entityName);
|
||||
$comment->setAuthorId($profileId);
|
||||
$comment->setAuthorName($authorName);
|
||||
|
||||
$this->entityManager->persist($comment);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json($this->normalize($comment), 201);
|
||||
}
|
||||
|
||||
#[Route('/{id}/resolve', name: 'api_comments_resolve', methods: ['PATCH'])]
|
||||
public function resolve(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$comment = $this->entityManager->getRepository(Comment::class)->find($id);
|
||||
if (!$comment) {
|
||||
return $this->json(['message' => 'Commentaire introuvable.'], 404);
|
||||
}
|
||||
|
||||
$session = $request->getSession();
|
||||
$profileId = $session->get('profileId');
|
||||
$profile = $profileId ? $this->profiles->find($profileId) : null;
|
||||
|
||||
$resolverName = 'Inconnu';
|
||||
if ($profile) {
|
||||
$resolverName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ('' === $resolverName) {
|
||||
$resolverName = $profile->getEmail() ?? 'Inconnu';
|
||||
}
|
||||
}
|
||||
|
||||
$comment->setStatus('resolved');
|
||||
$comment->setResolvedById($profileId);
|
||||
$comment->setResolvedByName($resolverName);
|
||||
$comment->setResolvedAt(new DateTimeImmutable());
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json($this->normalize($comment));
|
||||
}
|
||||
|
||||
#[Route('/stats/unresolved-count', name: 'api_comments_unresolved_count', methods: ['GET'])]
|
||||
public function unresolvedCount(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$count = $this->entityManager->getRepository(Comment::class)
|
||||
->count(['status' => 'open'])
|
||||
;
|
||||
|
||||
return $this->json(['count' => $count]);
|
||||
}
|
||||
|
||||
private function normalize(Comment $comment): array
|
||||
{
|
||||
return [
|
||||
'id' => $comment->getId(),
|
||||
'content' => $comment->getContent(),
|
||||
'entityType' => $comment->getEntityType(),
|
||||
'entityId' => $comment->getEntityId(),
|
||||
'entityName' => $comment->getEntityName(),
|
||||
'authorId' => $comment->getAuthorId(),
|
||||
'authorName' => $comment->getAuthorName(),
|
||||
'status' => $comment->getStatus(),
|
||||
'resolvedById' => $comment->getResolvedById(),
|
||||
'resolvedByName' => $comment->getResolvedByName(),
|
||||
'resolvedAt' => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM),
|
||||
'createdAt' => $comment->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
'updatedAt' => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM),
|
||||
];
|
||||
}
|
||||
}
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ class CustomFieldValueController extends AbstractController
|
||||
#[Route('', name: 'custom_field_values_create', methods: ['POST'])]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$payload = $this->decodePayload($request);
|
||||
if ($payload instanceof JsonResponse) {
|
||||
return $payload;
|
||||
@@ -63,6 +65,8 @@ class CustomFieldValueController extends AbstractController
|
||||
#[Route('/upsert', name: 'custom_field_values_upsert', methods: ['POST'])]
|
||||
public function upsert(Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$payload = $this->decodePayload($request);
|
||||
if ($payload instanceof JsonResponse) {
|
||||
return $payload;
|
||||
@@ -104,6 +108,8 @@ class CustomFieldValueController extends AbstractController
|
||||
#[Route('/{entityType}/{entityId}', name: 'custom_field_values_list', methods: ['GET'])]
|
||||
public function listByEntity(string $entityType, string $entityId): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$target = $this->resolveTarget([
|
||||
'entityType' => $entityType,
|
||||
'entityId' => $entityId,
|
||||
@@ -126,6 +132,8 @@ class CustomFieldValueController extends AbstractController
|
||||
#[Route('/{id}', name: 'custom_field_values_update', methods: ['PATCH'])]
|
||||
public function update(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$value = $this->customFieldValueRepository->find($id);
|
||||
if (!$value instanceof CustomFieldValue) {
|
||||
return $this->json(['success' => false, 'error' => 'Custom field value not found.'], 404);
|
||||
@@ -148,6 +156,8 @@ class CustomFieldValueController extends AbstractController
|
||||
#[Route('/{id}', name: 'custom_field_values_delete', methods: ['DELETE'])]
|
||||
public function delete(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$value = $this->customFieldValueRepository->find($id);
|
||||
if (!$value instanceof CustomFieldValue) {
|
||||
return $this->json(['success' => false, 'error' => 'Custom field value not found.'], 404);
|
||||
|
||||
@@ -30,6 +30,8 @@ class DocumentQueryController extends AbstractController
|
||||
#[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])]
|
||||
public function listBySite(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$site = $this->siteRepository->find($id);
|
||||
if (!$site) {
|
||||
return $this->json(['success' => false, 'error' => 'Site not found.'], 404);
|
||||
@@ -43,6 +45,8 @@ class DocumentQueryController extends AbstractController
|
||||
#[Route('/machine/{id}', name: 'documents_by_machine', methods: ['GET'])]
|
||||
public function listByMachine(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$machine = $this->machineRepository->find($id);
|
||||
if (!$machine) {
|
||||
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
|
||||
@@ -56,6 +60,8 @@ class DocumentQueryController extends AbstractController
|
||||
#[Route('/composant/{id}', name: 'documents_by_composant', methods: ['GET'])]
|
||||
public function listByComposant(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$composant = $this->composantRepository->find($id);
|
||||
if (!$composant) {
|
||||
return $this->json(['success' => false, 'error' => 'Composant not found.'], 404);
|
||||
@@ -69,6 +75,8 @@ class DocumentQueryController extends AbstractController
|
||||
#[Route('/piece/{id}', name: 'documents_by_piece', methods: ['GET'])]
|
||||
public function listByPiece(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$piece = $this->pieceRepository->find($id);
|
||||
if (!$piece) {
|
||||
return $this->json(['success' => false, 'error' => 'Piece not found.'], 404);
|
||||
@@ -82,6 +90,8 @@ class DocumentQueryController extends AbstractController
|
||||
#[Route('/product/{id}', name: 'documents_by_product', methods: ['GET'])]
|
||||
public function listByProduct(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$product = $this->productRepository->find($id);
|
||||
if (!$product) {
|
||||
return $this->json(['success' => false, 'error' => 'Product not found.'], 404);
|
||||
@@ -102,7 +112,8 @@ class DocumentQueryController extends AbstractController
|
||||
'id' => $document->getId(),
|
||||
'name' => $document->getName(),
|
||||
'filename' => $document->getFilename(),
|
||||
'path' => $document->getPath(),
|
||||
'fileUrl' => '/api/documents/'.$document->getId().'/file',
|
||||
'downloadUrl' => '/api/documents/'.$document->getId().'/download',
|
||||
'mimeType' => $document->getMimeType(),
|
||||
'size' => $document->getSize(),
|
||||
'siteId' => $document->getSite()?->getId(),
|
||||
|
||||
109
src/Controller/DocumentServeController.php
Normal file
109
src/Controller/DocumentServeController.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\DocumentRepository;
|
||||
use App\Service\DocumentStorageService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
use function strlen;
|
||||
|
||||
#[Route('/api/documents')]
|
||||
class DocumentServeController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DocumentRepository $documentRepository,
|
||||
private readonly DocumentStorageService $storageService,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}/file', name: 'document_serve_file', methods: ['GET'])]
|
||||
public function serve(string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$document = $this->documentRepository->find($id);
|
||||
if (!$document) {
|
||||
return $this->json(['error' => 'Document not found.'], 404);
|
||||
}
|
||||
|
||||
$path = $document->getPath();
|
||||
|
||||
// Backward compatibility: serve Base64 data URIs from DB
|
||||
if ($this->storageService->isBase64DataUri($path)) {
|
||||
$parts = explode(',', $path, 2);
|
||||
$content = base64_decode($parts[1] ?? '', true);
|
||||
if (false === $content) {
|
||||
return $this->json(['error' => 'Invalid document data.'], 500);
|
||||
}
|
||||
|
||||
return new Response($content, 200, [
|
||||
'Content-Type' => $document->getMimeType(),
|
||||
'Content-Disposition' => ResponseHeaderBag::DISPOSITION_INLINE.'; filename="'.$document->getFilename().'"',
|
||||
'Content-Length' => (string) strlen($content),
|
||||
'Cache-Control' => 'private, max-age=3600',
|
||||
]);
|
||||
}
|
||||
|
||||
// File-based path: serve from disk
|
||||
$absolutePath = $this->storageService->getAbsolutePath($path);
|
||||
if (!file_exists($absolutePath)) {
|
||||
return $this->json(['error' => 'File not found on disk.'], 404);
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($absolutePath);
|
||||
$response->headers->set('Content-Type', $document->getMimeType());
|
||||
$response->setContentDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_INLINE,
|
||||
$document->getFilename()
|
||||
);
|
||||
$response->headers->set('Cache-Control', 'private, max-age=3600');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
#[Route('/{id}/download', name: 'document_download_file', methods: ['GET'])]
|
||||
public function download(string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$document = $this->documentRepository->find($id);
|
||||
if (!$document) {
|
||||
return $this->json(['error' => 'Document not found.'], 404);
|
||||
}
|
||||
|
||||
$path = $document->getPath();
|
||||
|
||||
if ($this->storageService->isBase64DataUri($path)) {
|
||||
$parts = explode(',', $path, 2);
|
||||
$content = base64_decode($parts[1] ?? '', true);
|
||||
if (false === $content) {
|
||||
return $this->json(['error' => 'Invalid document data.'], 500);
|
||||
}
|
||||
|
||||
return new Response($content, 200, [
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Disposition' => ResponseHeaderBag::DISPOSITION_ATTACHMENT.'; filename="'.$document->getFilename().'"',
|
||||
'Content-Length' => (string) strlen($content),
|
||||
]);
|
||||
}
|
||||
|
||||
$absolutePath = $this->storageService->getAbsolutePath($path);
|
||||
if (!file_exists($absolutePath)) {
|
||||
return $this->json(['error' => 'File not found on disk.'], 404);
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($absolutePath);
|
||||
$response->setContentDisposition(
|
||||
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||
$document->getFilename()
|
||||
);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -6,32 +6,76 @@ namespace App\Controller;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class ComposantHistoryController
|
||||
final class EntityHistoryController extends AbstractController
|
||||
{
|
||||
/** @var array<string, array{repo: EntityRepository<object>, label: string}> */
|
||||
private readonly array $entityConfig;
|
||||
|
||||
public function __construct(
|
||||
private readonly ComposantRepository $components,
|
||||
MachineRepository $machines,
|
||||
PieceRepository $pieces,
|
||||
ComposantRepository $composants,
|
||||
ProductRepository $products,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
) {
|
||||
$this->entityConfig = [
|
||||
'machine' => ['repo' => $machines, 'label' => 'Machine introuvable.'],
|
||||
'piece' => ['repo' => $pieces, 'label' => 'Pièce introuvable.'],
|
||||
'composant' => ['repo' => $composants, 'label' => 'Composant introuvable.'],
|
||||
'product' => ['repo' => $products, 'label' => 'Produit introuvable.'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Route('/api/machines/{id}/history', name: 'api_machine_history', methods: ['GET'])]
|
||||
public function machineHistory(string $id): JsonResponse
|
||||
{
|
||||
return $this->entityHistory('machine', $id);
|
||||
}
|
||||
|
||||
#[Route('/api/pieces/{id}/history', name: 'api_piece_history', methods: ['GET'])]
|
||||
public function pieceHistory(string $id): JsonResponse
|
||||
{
|
||||
return $this->entityHistory('piece', $id);
|
||||
}
|
||||
|
||||
#[Route('/api/composants/{id}/history', name: 'api_composant_history', methods: ['GET'])]
|
||||
public function __invoke(string $id): JsonResponse
|
||||
public function composantHistory(string $id): JsonResponse
|
||||
{
|
||||
$component = $this->components->find($id);
|
||||
if (!$component) {
|
||||
return $this->entityHistory('composant', $id);
|
||||
}
|
||||
|
||||
#[Route('/api/products/{id}/history', name: 'api_product_history', methods: ['GET'])]
|
||||
public function productHistory(string $id): JsonResponse
|
||||
{
|
||||
return $this->entityHistory('product', $id);
|
||||
}
|
||||
|
||||
private function entityHistory(string $type, string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$config = $this->entityConfig[$type];
|
||||
$entity = $config['repo']->find($id);
|
||||
if (!$entity) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Composant introuvable.'],
|
||||
['message' => $config['label']],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$logs = $this->auditLogs->findEntityHistory('composant', $id, 200);
|
||||
$logs = $this->auditLogs->findEntityHistory($type, $id, 200);
|
||||
|
||||
$actorIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($log) => $log->getActorProfileId(),
|
||||
57
src/Controller/HealthCheckController.php
Normal file
57
src/Controller/HealthCheckController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use DateTimeInterface;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Throwable;
|
||||
|
||||
class HealthCheckController extends AbstractController
|
||||
{
|
||||
#[Route('/api/health', name: 'api_health', methods: ['GET'])]
|
||||
public function __invoke(Connection $connection): JsonResponse
|
||||
{
|
||||
$dbOk = false;
|
||||
|
||||
try {
|
||||
$start = hrtime(true);
|
||||
$connection->executeQuery('SELECT 1');
|
||||
$dbLatency = round((hrtime(true) - $start) / 1e6, 1);
|
||||
$dbOk = true;
|
||||
} catch (Throwable) {
|
||||
$dbLatency = null;
|
||||
}
|
||||
|
||||
$healthy = $dbOk;
|
||||
$data = ['status' => $healthy ? 'ok' : 'degraded'];
|
||||
|
||||
if ($this->isGranted('ROLE_ADMIN')) {
|
||||
$version = '0.0.0';
|
||||
$versionFile = $this->getParameter('kernel.project_dir').'/VERSION';
|
||||
if (file_exists($versionFile)) {
|
||||
$version = trim(file_get_contents($versionFile));
|
||||
}
|
||||
|
||||
$data += [
|
||||
'version' => $version,
|
||||
'timestamp' => new DateTimeImmutable()->format(DateTimeInterface::ATOM),
|
||||
'php' => PHP_VERSION,
|
||||
'checks' => [
|
||||
'database' => [
|
||||
'status' => $dbOk ? 'ok' : 'down',
|
||||
'latency_ms' => $dbLatency,
|
||||
],
|
||||
],
|
||||
'memory_mb' => round(memory_get_usage(true) / 1024 / 1024, 1),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->json($data, $healthy ? 200 : 503);
|
||||
}
|
||||
}
|
||||
@@ -26,17 +26,14 @@ class MachineCustomFieldsController extends AbstractController
|
||||
#[Route('/{id}/add-custom-fields', name: 'machine_add_custom_fields', methods: ['POST'])]
|
||||
public function addMissingCustomFields(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$machine = $this->machineRepository->find($id);
|
||||
if (!$machine instanceof Machine) {
|
||||
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
|
||||
}
|
||||
|
||||
$typeMachine = $machine->getTypeMachine();
|
||||
if (!$typeMachine) {
|
||||
return $this->json(['success' => true, 'machineId' => $machine->getId(), 'customFieldValues' => []]);
|
||||
}
|
||||
|
||||
foreach ($typeMachine->getCustomFields() as $customField) {
|
||||
foreach ($machine->getCustomFields() as $customField) {
|
||||
if (!$customField instanceof CustomField) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class MachineHistoryController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
|
||||
#[Route('/api/machines/{id}/history', name: 'api_machine_history', methods: ['GET'])]
|
||||
public function __invoke(string $id): JsonResponse
|
||||
{
|
||||
$machine = $this->machines->find($id);
|
||||
if (!$machine) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Machine introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$logs = $this->auditLogs->findEntityHistory('machine', $id, 200);
|
||||
|
||||
$actorIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($log) => $log->getActorProfileId(),
|
||||
$logs,
|
||||
))));
|
||||
|
||||
$actorMap = [];
|
||||
if ([] !== $actorIds) {
|
||||
$profiles = $this->profiles->findBy(['id' => $actorIds]);
|
||||
foreach ($profiles as $profile) {
|
||||
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ('' === $label) {
|
||||
$label = $profile->getEmail() ?? $profile->getId();
|
||||
}
|
||||
$actorMap[$profile->getId()] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
$items = array_map(
|
||||
static function ($log) use ($actorMap) {
|
||||
$actorId = $log->getActorProfileId();
|
||||
|
||||
return [
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
'actor' => $actorId
|
||||
? [
|
||||
'id' => $actorId,
|
||||
'label' => $actorMap[$actorId] ?? $actorId,
|
||||
]
|
||||
: null,
|
||||
'diff' => $log->getDiff(),
|
||||
'snapshot' => $log->getSnapshot(),
|
||||
];
|
||||
},
|
||||
$logs,
|
||||
);
|
||||
|
||||
return new JsonResponse([
|
||||
'items' => array_values($items),
|
||||
'total' => count($items),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Machine;
|
||||
use App\Entity\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
@@ -12,9 +14,7 @@ use App\Entity\MachineProductLink;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\TypeMachineComponentRequirement;
|
||||
use App\Entity\TypeMachinePieceRequirement;
|
||||
use App\Entity\TypeMachineProductRequirement;
|
||||
use App\Entity\Site;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
@@ -22,9 +22,6 @@ use App\Repository\MachineProductLinkRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\TypeMachineComponentRequirementRepository;
|
||||
use App\Repository\TypeMachinePieceRequirementRepository;
|
||||
use App\Repository\TypeMachineProductRequirementRepository;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -33,7 +30,7 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/machines')]
|
||||
class MachineSkeletonController extends AbstractController
|
||||
class MachineStructureController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
@@ -44,14 +41,13 @@ class MachineSkeletonController extends AbstractController
|
||||
private readonly ComposantRepository $composantRepository,
|
||||
private readonly PieceRepository $pieceRepository,
|
||||
private readonly ProductRepository $productRepository,
|
||||
private readonly TypeMachineComponentRequirementRepository $componentRequirementRepository,
|
||||
private readonly TypeMachinePieceRequirementRepository $pieceRequirementRepository,
|
||||
private readonly TypeMachineProductRequirementRepository $productRequirementRepository,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}/skeleton', name: 'machine_skeleton_get', methods: ['GET'])]
|
||||
public function getSkeleton(string $id): JsonResponse
|
||||
#[Route('/{id}/structure', name: 'machine_structure_get', methods: ['GET'])]
|
||||
public function getStructure(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$machine = $this->machineRepository->find($id);
|
||||
if (!$machine instanceof Machine) {
|
||||
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
|
||||
@@ -61,7 +57,7 @@ class MachineSkeletonController extends AbstractController
|
||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
|
||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
|
||||
|
||||
return $this->json($this->normalizeMachineSkeletonResponse(
|
||||
return $this->json($this->normalizeStructureResponse(
|
||||
$machine,
|
||||
$componentLinks,
|
||||
$pieceLinks,
|
||||
@@ -69,9 +65,11 @@ class MachineSkeletonController extends AbstractController
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/{id}/skeleton', name: 'machine_skeleton_update', methods: ['PATCH'])]
|
||||
public function updateSkeleton(string $id, Request $request): JsonResponse
|
||||
#[Route('/{id}/structure', name: 'machine_structure_update', methods: ['PATCH'])]
|
||||
public function updateStructure(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$machine = $this->machineRepository->find($id);
|
||||
if (!$machine instanceof Machine) {
|
||||
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
|
||||
@@ -103,7 +101,7 @@ class MachineSkeletonController extends AbstractController
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json($this->normalizeMachineSkeletonResponse(
|
||||
return $this->json($this->normalizeStructureResponse(
|
||||
$machine,
|
||||
$componentLinks,
|
||||
$pieceLinks,
|
||||
@@ -111,6 +109,195 @@ class MachineSkeletonController extends AbstractController
|
||||
));
|
||||
}
|
||||
|
||||
#[Route('/{id}/clone', name: 'machine_clone', methods: ['POST'])]
|
||||
public function cloneMachine(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$source = $this->machineRepository->find($id);
|
||||
if (!$source instanceof Machine) {
|
||||
return $this->json(['success' => false, 'error' => 'Machine source introuvable.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload) || empty($payload['name']) || empty($payload['siteId'])) {
|
||||
return $this->json(['success' => false, 'error' => 'name et siteId sont requis.'], 400);
|
||||
}
|
||||
|
||||
$site = $this->entityManager->getRepository(Site::class)->find($payload['siteId']);
|
||||
if (!$site) {
|
||||
return $this->json(['success' => false, 'error' => 'Site introuvable.'], 404);
|
||||
}
|
||||
|
||||
// Create new machine
|
||||
$newMachine = new Machine();
|
||||
$newMachine->setName($payload['name']);
|
||||
$newMachine->setSite($site);
|
||||
if (!empty($payload['reference'])) {
|
||||
$newMachine->setReference($payload['reference']);
|
||||
}
|
||||
$newMachine->setPrix($source->getPrix());
|
||||
|
||||
// Copy constructeurs
|
||||
foreach ($source->getConstructeurs() as $constructeur) {
|
||||
$newMachine->getConstructeurs()->add($constructeur);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($newMachine);
|
||||
|
||||
// Copy custom fields and values
|
||||
$this->cloneCustomFields($source, $newMachine);
|
||||
|
||||
// Copy component links (preserving hierarchy)
|
||||
$componentLinkMap = $this->cloneComponentLinks($source, $newMachine);
|
||||
|
||||
// Copy piece links
|
||||
$pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap);
|
||||
|
||||
// Copy product links
|
||||
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine]);
|
||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $newMachine]);
|
||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $newMachine]);
|
||||
|
||||
return $this->json($this->normalizeStructureResponse(
|
||||
$newMachine,
|
||||
$componentLinks,
|
||||
$pieceLinks,
|
||||
$productLinks
|
||||
), 201);
|
||||
}
|
||||
|
||||
private function cloneCustomFields(Machine $source, Machine $target): void
|
||||
{
|
||||
foreach ($source->getCustomFields() as $cf) {
|
||||
$newCf = new CustomField();
|
||||
$newCf->setName($cf->getName());
|
||||
$newCf->setType($cf->getType());
|
||||
$newCf->setRequired($cf->isRequired());
|
||||
$newCf->setDefaultValue($cf->getDefaultValue());
|
||||
$newCf->setOptions($cf->getOptions());
|
||||
$newCf->setOrderIndex($cf->getOrderIndex());
|
||||
$newCf->setMachine($target);
|
||||
$this->entityManager->persist($newCf);
|
||||
}
|
||||
|
||||
foreach ($source->getCustomFieldValues() as $cfv) {
|
||||
$newValue = new CustomFieldValue();
|
||||
$newValue->setMachine($target);
|
||||
$newValue->setCustomField($cfv->getCustomField());
|
||||
$newValue->setValue($cfv->getValue());
|
||||
$this->entityManager->persist($newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, MachineComponentLink> Map of old link ID → new link
|
||||
*/
|
||||
private function cloneComponentLinks(Machine $source, Machine $target): array
|
||||
{
|
||||
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
// First pass: create all links without parent relationships
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachineComponentLink();
|
||||
$newLink->setMachine($target);
|
||||
$newLink->setComposant($link->getComposant());
|
||||
$newLink->setNameOverride($link->getNameOverride());
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
$newLink->setPrixOverride($link->getPrixOverride());
|
||||
$this->entityManager->persist($newLink);
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
// Second pass: set parent relationships
|
||||
foreach ($sourceLinks as $link) {
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($linkMap[$parent->getId()])) {
|
||||
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
|
||||
}
|
||||
}
|
||||
|
||||
return $linkMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, MachineComponentLink> $componentLinkMap
|
||||
*
|
||||
* @return array<string, MachinePieceLink> Map of old link ID → new link
|
||||
*/
|
||||
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array
|
||||
{
|
||||
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachinePieceLink();
|
||||
$newLink->setMachine($target);
|
||||
$newLink->setPiece($link->getPiece());
|
||||
$newLink->setNameOverride($link->getNameOverride());
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
$newLink->setPrixOverride($link->getPrixOverride());
|
||||
$newLink->setQuantity($link->getQuantity());
|
||||
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($componentLinkMap[$parent->getId()])) {
|
||||
$newLink->setParentLink($componentLinkMap[$parent->getId()]);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($newLink);
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
return $linkMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, MachineComponentLink> $componentLinkMap
|
||||
* @param array<string, MachinePieceLink> $pieceLinkMap
|
||||
*/
|
||||
private function cloneProductLinks(
|
||||
Machine $source,
|
||||
Machine $target,
|
||||
array $componentLinkMap,
|
||||
array $pieceLinkMap,
|
||||
): void {
|
||||
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
// First pass: create all links
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachineProductLink();
|
||||
$newLink->setMachine($target);
|
||||
$newLink->setProduct($link->getProduct());
|
||||
|
||||
$parentComponent = $link->getParentComponentLink();
|
||||
if ($parentComponent && isset($componentLinkMap[$parentComponent->getId()])) {
|
||||
$newLink->setParentComponentLink($componentLinkMap[$parentComponent->getId()]);
|
||||
}
|
||||
|
||||
$parentPiece = $link->getParentPieceLink();
|
||||
if ($parentPiece && isset($pieceLinkMap[$parentPiece->getId()])) {
|
||||
$newLink->setParentPieceLink($pieceLinkMap[$parentPiece->getId()]);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($newLink);
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
// Second pass: set parent product link relationships
|
||||
foreach ($sourceLinks as $link) {
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($linkMap[$parent->getId()])) {
|
||||
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizePayloadList(mixed $value): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
@@ -139,7 +326,7 @@ class MachineSkeletonController extends AbstractController
|
||||
|
||||
$composantId = $this->resolveIdentifier($entry, ['composantId', 'componentId', 'idComposant']);
|
||||
if (!$composantId) {
|
||||
return $this->json(['success' => false, 'error' => 'Composant requis pour le squelette.'], 400);
|
||||
return $this->json(['success' => false, 'error' => 'Composant requis.'], 400);
|
||||
}
|
||||
$composant = $this->composantRepository->find($composantId);
|
||||
if (!$composant instanceof Composant) {
|
||||
@@ -149,14 +336,6 @@ class MachineSkeletonController extends AbstractController
|
||||
$link->setMachine($machine);
|
||||
$link->setComposant($composant);
|
||||
|
||||
$requirementId = $this->resolveIdentifier($entry, ['requirementId', 'typeMachineComponentRequirementId']);
|
||||
if ($requirementId) {
|
||||
$requirement = $this->componentRequirementRepository->find($requirementId);
|
||||
if ($requirement instanceof TypeMachineComponentRequirement) {
|
||||
$link->setTypeMachineComponentRequirement($requirement);
|
||||
}
|
||||
}
|
||||
|
||||
$this->applyOverrides($link, $entry['overrides'] ?? null);
|
||||
|
||||
$pendingParents[$linkId] = $this->resolveIdentifier($entry, [
|
||||
@@ -171,10 +350,7 @@ class MachineSkeletonController extends AbstractController
|
||||
}
|
||||
|
||||
foreach ($pendingParents as $linkId => $parentId) {
|
||||
if (!$parentId) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($links[$linkId])) {
|
||||
if (!$parentId || !isset($links[$linkId])) {
|
||||
continue;
|
||||
}
|
||||
$parent = $links[$parentId] ?? $existing[$parentId] ?? null;
|
||||
@@ -208,7 +384,7 @@ class MachineSkeletonController extends AbstractController
|
||||
|
||||
$pieceId = $this->resolveIdentifier($entry, ['pieceId']);
|
||||
if (!$pieceId) {
|
||||
return $this->json(['success' => false, 'error' => 'Pièce requise pour le squelette.'], 400);
|
||||
return $this->json(['success' => false, 'error' => 'Pièce requise.'], 400);
|
||||
}
|
||||
$piece = $this->pieceRepository->find($pieceId);
|
||||
if (!$piece instanceof Piece) {
|
||||
@@ -218,16 +394,13 @@ class MachineSkeletonController extends AbstractController
|
||||
$link->setMachine($machine);
|
||||
$link->setPiece($piece);
|
||||
|
||||
$requirementId = $this->resolveIdentifier($entry, ['requirementId', 'typeMachinePieceRequirementId']);
|
||||
if ($requirementId) {
|
||||
$requirement = $this->pieceRequirementRepository->find($requirementId);
|
||||
if ($requirement instanceof TypeMachinePieceRequirement) {
|
||||
$link->setTypeMachinePieceRequirement($requirement);
|
||||
}
|
||||
}
|
||||
|
||||
$this->applyOverrides($link, $entry['overrides'] ?? null);
|
||||
|
||||
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',
|
||||
@@ -240,10 +413,7 @@ class MachineSkeletonController extends AbstractController
|
||||
}
|
||||
|
||||
foreach ($pendingParents as $linkId => $parentId) {
|
||||
if (!$parentId) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($links[$linkId])) {
|
||||
if (!$parentId || !isset($links[$linkId])) {
|
||||
continue;
|
||||
}
|
||||
$parent = $componentIndex[$parentId] ?? null;
|
||||
@@ -282,7 +452,7 @@ class MachineSkeletonController extends AbstractController
|
||||
|
||||
$productId = $this->resolveIdentifier($entry, ['productId']);
|
||||
if (!$productId) {
|
||||
return $this->json(['success' => false, 'error' => 'Produit requis pour le squelette.'], 400);
|
||||
return $this->json(['success' => false, 'error' => 'Produit requis.'], 400);
|
||||
}
|
||||
$product = $this->productRepository->find($productId);
|
||||
if (!$product instanceof Product) {
|
||||
@@ -292,14 +462,6 @@ class MachineSkeletonController extends AbstractController
|
||||
$link->setMachine($machine);
|
||||
$link->setProduct($product);
|
||||
|
||||
$requirementId = $this->resolveIdentifier($entry, ['requirementId', 'typeMachineProductRequirementId']);
|
||||
if ($requirementId) {
|
||||
$requirement = $this->productRequirementRepository->find($requirementId);
|
||||
if ($requirement instanceof TypeMachineProductRequirement) {
|
||||
$link->setTypeMachineProductRequirement($requirement);
|
||||
}
|
||||
}
|
||||
|
||||
$pendingParents[$linkId] = [
|
||||
'parentComponentLinkId' => $this->resolveIdentifier($entry, ['parentComponentLinkId']),
|
||||
'parentPieceLinkId' => $this->resolveIdentifier($entry, ['parentPieceLinkId']),
|
||||
@@ -331,7 +493,7 @@ class MachineSkeletonController extends AbstractController
|
||||
return array_values($links);
|
||||
}
|
||||
|
||||
private function normalizeMachineSkeletonResponse(
|
||||
private function normalizeStructureResponse(
|
||||
Machine $machine,
|
||||
array $componentLinks,
|
||||
array $pieceLinks,
|
||||
@@ -341,21 +503,25 @@ class MachineSkeletonController extends AbstractController
|
||||
$componentIndex = $this->indexNormalizedLinks($normalizedComponentLinks);
|
||||
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
|
||||
|
||||
// Build component hierarchy
|
||||
foreach ($normalizedComponentLinks as &$link) {
|
||||
$childIds = [];
|
||||
foreach ($normalizedComponentLinks as $link) {
|
||||
$parentId = $link['parentComponentLinkId'] ?? null;
|
||||
if ($parentId && isset($componentIndex[$parentId])) {
|
||||
$componentIndex[$parentId]['childLinks'][] = &$link;
|
||||
$componentIndex[$parentId]['childLinks'][] = $link;
|
||||
$childIds[$link['id']] = true;
|
||||
}
|
||||
}
|
||||
unset($link);
|
||||
|
||||
// Add pieces to components recursively
|
||||
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
|
||||
|
||||
$rootComponents = array_filter(
|
||||
$componentIndex,
|
||||
static fn (array $link) => !isset($childIds[$link['id']]),
|
||||
);
|
||||
|
||||
return [
|
||||
'machine' => $this->normalizeMachine($machine),
|
||||
'componentLinks' => array_values($componentIndex),
|
||||
'componentLinks' => array_values($rootComponents),
|
||||
'pieceLinks' => $normalizedPieceLinks,
|
||||
'productLinks' => $this->normalizeProductLinks($productLinks),
|
||||
];
|
||||
@@ -370,7 +536,6 @@ class MachineSkeletonController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively attach to child components
|
||||
foreach ($componentIndex as &$component) {
|
||||
if (!empty($component['childLinks'])) {
|
||||
$this->attachPiecesToChildComponents($component['childLinks'], $pieceLinks);
|
||||
@@ -391,7 +556,6 @@ class MachineSkeletonController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively process nested children
|
||||
if (!empty($child['childLinks'])) {
|
||||
$this->attachPiecesToChildComponents($child['childLinks'], $pieceLinks);
|
||||
}
|
||||
@@ -400,8 +564,7 @@ class MachineSkeletonController extends AbstractController
|
||||
|
||||
private function normalizeMachine(Machine $machine): array
|
||||
{
|
||||
$site = $machine->getSite();
|
||||
$typeMachine = $machine->getTypeMachine();
|
||||
$site = $machine->getSite();
|
||||
|
||||
return [
|
||||
'id' => $machine->getId(),
|
||||
@@ -413,26 +576,10 @@ class MachineSkeletonController extends AbstractController
|
||||
'id' => $site->getId(),
|
||||
'name' => $site->getName(),
|
||||
],
|
||||
'typeMachineId' => $typeMachine?->getId(),
|
||||
'typeMachine' => $typeMachine ? [
|
||||
'id' => $typeMachine->getId(),
|
||||
'name' => $typeMachine->getName(),
|
||||
'category' => $typeMachine->getCategory(),
|
||||
'description' => $typeMachine->getDescription(),
|
||||
'customFields' => $this->normalizeCustomFields($typeMachine->getCustomFields()),
|
||||
'componentRequirements' => $typeMachine->getComponentRequirements()
|
||||
->map(fn (TypeMachineComponentRequirement $req) => $this->normalizeComponentRequirement($req))
|
||||
->toArray(),
|
||||
'pieceRequirements' => $typeMachine->getPieceRequirements()
|
||||
->map(fn (TypeMachinePieceRequirement $req) => $this->normalizePieceRequirement($req))
|
||||
->toArray(),
|
||||
'productRequirements' => $typeMachine->getProductRequirements()
|
||||
->map(fn (TypeMachineProductRequirement $req) => $this->normalizeProductRequirement($req))
|
||||
->toArray(),
|
||||
] : null,
|
||||
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
|
||||
'customFields' => $this->normalizeCustomFields($machine->getCustomFields()),
|
||||
'documents' => null,
|
||||
'customFieldValues' => null,
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -460,26 +607,21 @@ class MachineSkeletonController extends AbstractController
|
||||
private function normalizeComponentLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineComponentLink $link): array {
|
||||
$composant = $link->getComposant();
|
||||
$requirement = $link->getTypeMachineComponentRequirement();
|
||||
$parentLink = $link->getParentLink();
|
||||
$parentRequirementId = $parentLink?->getTypeMachineComponentRequirement()?->getId();
|
||||
$composant = $link->getComposant();
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'composantId' => $composant->getId(),
|
||||
'composant' => $this->normalizeComposant($composant),
|
||||
'typeMachineComponentRequirementId' => $requirement?->getId(),
|
||||
'typeMachineComponentRequirement' => $requirement ? $this->normalizeComponentRequirement($requirement) : null,
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'parentMachineComponentRequirementId' => $parentRequirementId,
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'childLinks' => [],
|
||||
'pieceLinks' => [],
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'composantId' => $composant->getId(),
|
||||
'composant' => $this->normalizeComposant($composant),
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'childLinks' => [],
|
||||
'pieceLinks' => [],
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
@@ -487,95 +629,160 @@ class MachineSkeletonController extends AbstractController
|
||||
private function normalizePieceLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachinePieceLink $link): array {
|
||||
$piece = $link->getPiece();
|
||||
$requirement = $link->getTypeMachinePieceRequirement();
|
||||
$parentLink = $link->getParentLink();
|
||||
$parentRequirementId = $parentLink?->getTypeMachineComponentRequirement()?->getId();
|
||||
$piece = $link->getPiece();
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'pieceId' => $piece->getId(),
|
||||
'piece' => $this->normalizePiece($piece),
|
||||
'typeMachinePieceRequirementId' => $requirement?->getId(),
|
||||
'typeMachinePieceRequirement' => $requirement ? $this->normalizePieceRequirement($requirement) : null,
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'parentMachineComponentRequirementId' => $parentRequirementId,
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'pieceId' => $piece->getId(),
|
||||
'piece' => $this->normalizePiece($piece),
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'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 {
|
||||
$product = $link->getProduct();
|
||||
$requirement = $link->getTypeMachineProductRequirement();
|
||||
$product = $link->getProduct();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'productId' => $product->getId(),
|
||||
'product' => $this->normalizeProduct($product),
|
||||
'typeMachineProductRequirementId' => $requirement?->getId(),
|
||||
'typeMachineProductRequirement' => $requirement ? $this->normalizeProductRequirement($requirement) : null,
|
||||
'parentLinkId' => $link->getParentLink()?->getId(),
|
||||
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
|
||||
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'productId' => $product->getId(),
|
||||
'product' => $this->normalizeProduct($product),
|
||||
'parentLinkId' => $link->getParentLink()?->getId(),
|
||||
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
|
||||
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
private function normalizeComposant(Composant $composant): array
|
||||
{
|
||||
$type = $composant->getTypeComposant();
|
||||
|
||||
return [
|
||||
'id' => $composant->getId(),
|
||||
'name' => $composant->getName(),
|
||||
'reference' => $composant->getReference(),
|
||||
'prix' => $composant->getPrix(),
|
||||
'typeComposantId' => $composant->getTypeComposant()?->getId(),
|
||||
'typeComposant' => $this->normalizeModelType($composant->getTypeComposant()),
|
||||
'productId' => $composant->getProduct()?->getId(),
|
||||
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
|
||||
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => [],
|
||||
'id' => $composant->getId(),
|
||||
'name' => $composant->getName(),
|
||||
'reference' => $composant->getReference(),
|
||||
'prix' => $composant->getPrix(),
|
||||
'typeComposantId' => $type?->getId(),
|
||||
'typeComposant' => $this->normalizeModelType($type),
|
||||
'productId' => $composant->getProduct()?->getId(),
|
||||
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
|
||||
'structure' => $this->buildStructureFromSlots($composant),
|
||||
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [],
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($composant->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
return [
|
||||
'id' => $piece->getId(),
|
||||
'name' => $piece->getName(),
|
||||
'reference' => $piece->getReference(),
|
||||
'prix' => $piece->getPrix(),
|
||||
'typePieceId' => $piece->getTypePiece()?->getId(),
|
||||
'typePiece' => $this->normalizeModelType($piece->getTypePiece()),
|
||||
'productId' => $piece->getProduct()?->getId(),
|
||||
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
|
||||
'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => [],
|
||||
'id' => $piece->getId(),
|
||||
'name' => $piece->getName(),
|
||||
'reference' => $piece->getReference(),
|
||||
'prix' => $piece->getPrix(),
|
||||
'typePieceId' => $type?->getId(),
|
||||
'typePiece' => $this->normalizeModelType($type),
|
||||
'productId' => $piece->getProduct()?->getId(),
|
||||
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
|
||||
'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getPieceCustomFields()) : [],
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($piece->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
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()),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -586,49 +793,11 @@ class MachineSkeletonController extends AbstractController
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $type->getId(),
|
||||
'name' => $type->getName(),
|
||||
'code' => $type->getCode(),
|
||||
'category' => $type->getCategory()->value,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeComponentRequirement(TypeMachineComponentRequirement $requirement): array
|
||||
{
|
||||
return [
|
||||
'id' => $requirement->getId(),
|
||||
'label' => $requirement->getLabel(),
|
||||
'minCount' => $requirement->getMinCount(),
|
||||
'maxCount' => $requirement->getMaxCount(),
|
||||
'required' => $requirement->isRequired(),
|
||||
'typeComposantId' => $requirement->getTypeComposant()->getId(),
|
||||
'typeComposant' => $this->normalizeModelType($requirement->getTypeComposant()),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePieceRequirement(TypeMachinePieceRequirement $requirement): array
|
||||
{
|
||||
return [
|
||||
'id' => $requirement->getId(),
|
||||
'label' => $requirement->getLabel(),
|
||||
'minCount' => $requirement->getMinCount(),
|
||||
'maxCount' => $requirement->getMaxCount(),
|
||||
'required' => $requirement->isRequired(),
|
||||
'typePieceId' => $requirement->getTypePiece()->getId(),
|
||||
'typePiece' => $this->normalizeModelType($requirement->getTypePiece()),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeProductRequirement(TypeMachineProductRequirement $requirement): array
|
||||
{
|
||||
return [
|
||||
'id' => $requirement->getId(),
|
||||
'label' => $requirement->getLabel(),
|
||||
'minCount' => $requirement->getMinCount(),
|
||||
'maxCount' => $requirement->getMaxCount(),
|
||||
'required' => $requirement->isRequired(),
|
||||
'typeProductId' => $requirement->getTypeProduct()->getId(),
|
||||
'typeProduct' => $this->normalizeModelType($requirement->getTypeProduct()),
|
||||
'id' => $type->getId(),
|
||||
'name' => $type->getName(),
|
||||
'code' => $type->getCode(),
|
||||
'category' => $type->getCategory()->value,
|
||||
'structure' => $type->getStructure(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -647,6 +816,55 @@ class MachineSkeletonController extends AbstractController
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeCustomFieldDefinitions(Collection $customFields): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFields as $cf) {
|
||||
if (!$cf instanceof CustomField) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeCustomFieldValues(Collection $customFieldValues): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFieldValues as $cfv) {
|
||||
if (!$cfv instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
$cf = $cfv->getCustomField();
|
||||
$items[] = [
|
||||
'id' => $cfv->getId(),
|
||||
'value' => $cfv->getValue(),
|
||||
'customField' => [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeOverrides(object $link): ?array
|
||||
{
|
||||
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;
|
||||
@@ -6,11 +6,12 @@ namespace App\Controller;
|
||||
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use App\Service\ModelTypeCategoryConversionService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class ModelTypeConversionController
|
||||
final class ModelTypeConversionController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
@@ -20,6 +21,8 @@ final class ModelTypeConversionController
|
||||
#[Route('/api/model_types/{id}/conversion-check', name: 'api_model_type_conversion_check', methods: ['GET'])]
|
||||
public function check(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||
|
||||
$modelType = $this->modelTypes->find($id);
|
||||
|
||||
if (!$modelType) {
|
||||
@@ -35,6 +38,8 @@ final class ModelTypeConversionController
|
||||
#[Route('/api/model_types/{id}/convert', name: 'api_model_type_convert', methods: ['POST'])]
|
||||
public function convert(string $id): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$modelType = $this->modelTypes->find($id);
|
||||
|
||||
if (!$modelType) {
|
||||
|
||||
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,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class PieceHistoryController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PieceRepository $pieces,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
|
||||
#[Route('/api/pieces/{id}/history', name: 'api_piece_history', methods: ['GET'])]
|
||||
public function __invoke(string $id): JsonResponse
|
||||
{
|
||||
$piece = $this->pieces->find($id);
|
||||
if (!$piece) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Pièce introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$logs = $this->auditLogs->findEntityHistory('piece', $id, 200);
|
||||
|
||||
$actorIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($log) => $log->getActorProfileId(),
|
||||
$logs,
|
||||
))));
|
||||
|
||||
$actorMap = [];
|
||||
if ([] !== $actorIds) {
|
||||
$profiles = $this->profiles->findBy(['id' => $actorIds]);
|
||||
foreach ($profiles as $profile) {
|
||||
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ('' === $label) {
|
||||
$label = $profile->getEmail() ?? $profile->getId();
|
||||
}
|
||||
$actorMap[$profile->getId()] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
$items = array_map(
|
||||
static function ($log) use ($actorMap) {
|
||||
$actorId = $log->getActorProfileId();
|
||||
|
||||
return [
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
'actor' => $actorId
|
||||
? [
|
||||
'id' => $actorId,
|
||||
'label' => $actorMap[$actorId] ?? $actorId,
|
||||
]
|
||||
: null,
|
||||
'diff' => $log->getDiff(),
|
||||
'snapshot' => $log->getSnapshot(),
|
||||
];
|
||||
},
|
||||
$logs,
|
||||
);
|
||||
|
||||
return new JsonResponse([
|
||||
'items' => array_values($items),
|
||||
'total' => count($items),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class ProductHistoryController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProductRepository $products,
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
|
||||
#[Route('/api/products/{id}/history', name: 'api_product_history', methods: ['GET'])]
|
||||
public function __invoke(string $id): JsonResponse
|
||||
{
|
||||
$product = $this->products->find($id);
|
||||
if (!$product) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Produit introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$logs = $this->auditLogs->findEntityHistory('product', $id, 200);
|
||||
|
||||
$actorIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn ($log) => $log->getActorProfileId(),
|
||||
$logs,
|
||||
))));
|
||||
|
||||
$actorMap = [];
|
||||
if ([] !== $actorIds) {
|
||||
$profiles = $this->profiles->findBy(['id' => $actorIds]);
|
||||
foreach ($profiles as $profile) {
|
||||
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ('' === $label) {
|
||||
$label = $profile->getEmail() ?? $profile->getId();
|
||||
}
|
||||
$actorMap[$profile->getId()] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
$items = array_map(
|
||||
static function ($log) use ($actorMap) {
|
||||
$actorId = $log->getActorProfileId();
|
||||
|
||||
return [
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
'actor' => $actorId
|
||||
? [
|
||||
'id' => $actorId,
|
||||
'label' => $actorMap[$actorId] ?? $actorId,
|
||||
]
|
||||
: null,
|
||||
'diff' => $log->getDiff(),
|
||||
'snapshot' => $log->getSnapshot(),
|
||||
];
|
||||
},
|
||||
$logs,
|
||||
);
|
||||
|
||||
return new JsonResponse([
|
||||
'items' => array_values($items),
|
||||
'total' => count($items),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,15 @@ use App\Repository\ProfileRepository;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Session\SessionInterface;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SessionProfileController
|
||||
{
|
||||
public function __construct(private readonly ProfileRepository $profiles) {}
|
||||
public function __construct(
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {}
|
||||
|
||||
#[Route('/api/session/profile', name: 'api_session_profile_get', methods: ['GET'])]
|
||||
public function getActiveProfile(Request $request): JsonResponse
|
||||
@@ -64,7 +68,25 @@ final class SessionProfileController
|
||||
return new JsonResponse(['message' => 'Profil introuvable ou inactif.'], JsonResponse::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$password = $payload['password'] ?? '';
|
||||
if ('' === $password) {
|
||||
return new JsonResponse(['message' => 'Mot de passe requis.'], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!$profile->getPassword()) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Ce profil n\'a pas de mot de passe. Contactez un administrateur.'],
|
||||
JsonResponse::HTTP_FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->passwordHasher->isPasswordValid($profile, $password)) {
|
||||
return new JsonResponse(['message' => 'Mot de passe incorrect.'], JsonResponse::HTTP_UNAUTHORIZED);
|
||||
}
|
||||
|
||||
$session->migrate(true);
|
||||
$session->set('profileId', $profile->getId());
|
||||
$session->set('profileRoles', $profile->getRoles());
|
||||
|
||||
return new JsonResponse([
|
||||
'id' => $profile->getId(),
|
||||
|
||||
@@ -4,18 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Profile;
|
||||
use App\Repository\ProfileRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class SessionProfilesController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly EntityManagerInterface $entityManager
|
||||
) {}
|
||||
|
||||
#[Route('/api/session/profiles', name: 'api_session_profiles_list', methods: ['GET'])]
|
||||
@@ -29,52 +25,13 @@ final class SessionProfilesController
|
||||
->getResult()
|
||||
;
|
||||
|
||||
return new JsonResponse(array_map([$this, 'serializeProfile'], $items));
|
||||
}
|
||||
|
||||
#[Route('/api/session/profiles', name: 'api_session_profiles_create', methods: ['POST'])]
|
||||
public function create(Request $request): JsonResponse
|
||||
{
|
||||
$payload = $request->toArray();
|
||||
$firstName = trim((string) ($payload['firstName'] ?? ''));
|
||||
$lastName = trim((string) ($payload['lastName'] ?? ''));
|
||||
|
||||
if ('' === $firstName || '' === $lastName) {
|
||||
return new JsonResponse(['message' => 'firstName et lastName sont requis.'], JsonResponse::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$profile = new Profile();
|
||||
$profile->setFirstName($firstName);
|
||||
$profile->setLastName($lastName);
|
||||
$profile->setIsActive(true);
|
||||
|
||||
$this->entityManager->persist($profile);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse($this->serializeProfile($profile), JsonResponse::HTTP_CREATED);
|
||||
}
|
||||
|
||||
#[Route('/api/session/profiles/{id}', name: 'api_session_profiles_delete', methods: ['DELETE'])]
|
||||
public function delete(string $id): JsonResponse
|
||||
{
|
||||
$profile = $this->profiles->find($id);
|
||||
if (!$profile) {
|
||||
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$profile->setIsActive(false);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['success' => true]);
|
||||
}
|
||||
|
||||
private function serializeProfile(Profile $profile): array
|
||||
{
|
||||
return [
|
||||
'id' => $profile->getId(),
|
||||
'firstName' => $profile->getFirstName(),
|
||||
'lastName' => $profile->getLastName(),
|
||||
'isActive' => $profile->isActive(),
|
||||
];
|
||||
return new JsonResponse(array_map(static function ($profile): array {
|
||||
return [
|
||||
'id' => $profile->getId(),
|
||||
'firstName' => $profile->getFirstName(),
|
||||
'lastName' => $profile->getLastName(),
|
||||
'hasPassword' => null !== $profile->getPassword() && '' !== $profile->getPassword(),
|
||||
];
|
||||
}, $items));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
207
src/Entity/Comment.php
Normal file
207
src/Entity/Comment.php
Normal file
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'comments')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_comment_entity_status')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['entityType' => 'exact', 'entityId' => 'exact', 'status' => 'exact', 'entityName' => 'ipartial'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'authorName', 'status'])]
|
||||
#[ApiResource(
|
||||
description: 'Commentaires et annotations. Permet aux utilisateurs de commenter les machines, pièces, composants, produits et catégories. Les commentaires peuvent être marqués comme résolus.',
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
order: ['createdAt' => 'DESC'],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class Comment
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
private string $content;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 50, name: 'entity_type')]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 36, name: 'entity_id')]
|
||||
private string $entityId;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'entity_name')]
|
||||
private ?string $entityName = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 36, name: 'author_id')]
|
||||
private string $authorId;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, name: 'author_name')]
|
||||
private string $authorName;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 20)]
|
||||
private string $status = 'open';
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 36, nullable: true, name: 'resolved_by_id')]
|
||||
private ?string $resolvedById = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'resolved_by_name')]
|
||||
private ?string $resolvedByName = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true, name: 'resolved_at')]
|
||||
private ?DateTimeImmutable $resolvedAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'created_at')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getContent(): string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
public function setContent(string $content): static
|
||||
{
|
||||
$this->content = $content;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEntityType(): string
|
||||
{
|
||||
return $this->entityType;
|
||||
}
|
||||
|
||||
public function setEntityType(string $entityType): static
|
||||
{
|
||||
$this->entityType = $entityType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEntityId(): string
|
||||
{
|
||||
return $this->entityId;
|
||||
}
|
||||
|
||||
public function setEntityId(string $entityId): static
|
||||
{
|
||||
$this->entityId = $entityId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEntityName(): ?string
|
||||
{
|
||||
return $this->entityName;
|
||||
}
|
||||
|
||||
public function setEntityName(?string $entityName): static
|
||||
{
|
||||
$this->entityName = $entityName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAuthorId(): string
|
||||
{
|
||||
return $this->authorId;
|
||||
}
|
||||
|
||||
public function setAuthorId(string $authorId): static
|
||||
{
|
||||
$this->authorId = $authorId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAuthorName(): string
|
||||
{
|
||||
return $this->authorName;
|
||||
}
|
||||
|
||||
public function setAuthorName(string $authorName): static
|
||||
{
|
||||
$this->authorName = $authorName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStatus(): string
|
||||
{
|
||||
return $this->status;
|
||||
}
|
||||
|
||||
public function setStatus(string $status): static
|
||||
{
|
||||
$this->status = $status;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getResolvedById(): ?string
|
||||
{
|
||||
return $this->resolvedById;
|
||||
}
|
||||
|
||||
public function setResolvedById(?string $resolvedById): static
|
||||
{
|
||||
$this->resolvedById = $resolvedById;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getResolvedByName(): ?string
|
||||
{
|
||||
return $this->resolvedByName;
|
||||
}
|
||||
|
||||
public function setResolvedByName(?string $resolvedByName): static
|
||||
{
|
||||
$this->resolvedByName = $resolvedByName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getResolvedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->resolvedAt;
|
||||
}
|
||||
|
||||
public function setResolvedAt(?DateTimeImmutable $resolvedAt): static
|
||||
{
|
||||
$this->resolvedAt = $resolvedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@ use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Repository\ComposantRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -19,15 +26,26 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Entity(repositoryClass: ComposantRepository::class)]
|
||||
#[ORM\Table(name: 'composants')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact', 'typeComposant.name' => 'ipartial'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
|
||||
#[ApiResource(
|
||||
description: 'Composants du catalogue. Un composant représente un élément fonctionnel rattaché à une machine, avec un type, des fournisseurs et des documents.',
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['composant:read']],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class Composant
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['composant:read', 'document:list'])]
|
||||
@@ -41,14 +59,14 @@ class Composant
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $reference = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $prix = null;
|
||||
|
||||
#[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'])]
|
||||
@@ -91,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;
|
||||
@@ -101,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
|
||||
@@ -161,6 +179,18 @@ class Composant
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): static
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPrix(): ?string
|
||||
{
|
||||
return $this->prix;
|
||||
@@ -173,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;
|
||||
@@ -265,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;
|
||||
}
|
||||
}
|
||||
@@ -5,28 +5,50 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')]
|
||||
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
|
||||
#[ORM\Table(name: 'constructeurs')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource(
|
||||
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class Constructeur
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||
private string $name;
|
||||
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $email = null;
|
||||
@@ -66,43 +88,15 @@ class Constructeur
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->machines = new ArrayCollection();
|
||||
$this->composants = new ArrayCollection();
|
||||
$this->pieces = new ArrayCollection();
|
||||
$this->products = new ArrayCollection();
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
@@ -137,19 +131,4 @@ class Constructeur
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Repository\CustomFieldRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -16,9 +23,21 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Entity(repositoryClass: CustomFieldRepository::class)]
|
||||
#[ORM\Table(name: 'custom_fields')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
#[ApiResource(
|
||||
description: 'Définitions de champs personnalisés. Permet de créer des champs dynamiques (texte, nombre, date, etc.) applicables aux machines, pièces, composants et produits.',
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
]
|
||||
)]
|
||||
class CustomField
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
@@ -47,9 +66,9 @@ class CustomField
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private int $orderIndex = 0;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TypeMachine::class, inversedBy: 'customFields')]
|
||||
#[ORM\JoinColumn(name: 'typeMachineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?TypeMachine $typeMachine = null;
|
||||
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'customFields')]
|
||||
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Machine $machine = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'customFields')]
|
||||
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
@@ -77,39 +96,11 @@ class CustomField
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->customFieldValues = new ArrayCollection();
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
@@ -182,30 +173,51 @@ class CustomField
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeMachine(): ?TypeMachine
|
||||
public function getMachine(): ?Machine
|
||||
{
|
||||
return $this->typeMachine;
|
||||
return $this->machine;
|
||||
}
|
||||
|
||||
public function setTypeMachine(?TypeMachine $typeMachine): static
|
||||
public function setMachine(?Machine $machine): static
|
||||
{
|
||||
$this->typeMachine = $typeMachine;
|
||||
$this->machine = $machine;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Repository\CustomFieldValueRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
@@ -14,9 +21,21 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Entity(repositoryClass: CustomFieldValueRepository::class)]
|
||||
#[ORM\Table(name: 'custom_field_values')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
#[ApiResource(
|
||||
description: 'Valeurs des champs personnalisés. Stocke la valeur concrète d\'un champ personnalisé pour une entité donnée (machine, pièce, composant ou produit).',
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
]
|
||||
)]
|
||||
class CustomFieldValue
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
@@ -55,36 +74,12 @@ class CustomFieldValue
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getValue(): string
|
||||
{
|
||||
return $this->value;
|
||||
@@ -156,19 +151,4 @@ class CustomFieldValue
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,19 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Repository\DocumentRepository;
|
||||
use App\State\DocumentUploadProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
@@ -19,19 +25,37 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Entity(repositoryClass: DocumentRepository::class)]
|
||||
#[ORM\Table(name: 'documents')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'filename' => 'ipartial'])]
|
||||
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])]
|
||||
#[ApiResource(
|
||||
description: 'Documents et fichiers. Gestion des fichiers joints (PDF, images, etc.) rattachés aux machines, pièces, composants, produits ou sites. Upload via multipart/form-data.',
|
||||
operations: [
|
||||
new GetCollection(normalizationContext: ['groups' => ['document:list']]),
|
||||
new Get(normalizationContext: ['groups' => ['document:list', 'document:detail']]),
|
||||
new Post(),
|
||||
new Put(),
|
||||
new Delete(),
|
||||
new GetCollection(
|
||||
security: "is_granted('ROLE_VIEWER')",
|
||||
normalizationContext: ['groups' => ['document:list']],
|
||||
),
|
||||
new Get(
|
||||
security: "is_granted('ROLE_VIEWER')",
|
||||
normalizationContext: ['groups' => ['document:list', 'document:detail']],
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_GESTIONNAIRE')",
|
||||
processor: DocumentUploadProcessor::class,
|
||||
deserialize: false,
|
||||
inputFormats: ['multipart' => ['multipart/form-data']],
|
||||
),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 200
|
||||
paginationMaximumItemsPerPage: 500,
|
||||
order: ['createdAt' => 'DESC']
|
||||
)]
|
||||
class Document
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
|
||||
@@ -46,7 +70,6 @@ class Document
|
||||
private string $filename;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT)]
|
||||
#[Groups(['document:detail', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
|
||||
private string $path;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 100, name: 'mimeType')]
|
||||
@@ -89,36 +112,12 @@ class Document
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
@@ -238,19 +237,4 @@ class Document
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Repository\MachineRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -12,13 +19,25 @@ use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: MachineRepository::class)]
|
||||
#[ORM\Table(name: 'machines')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
#[ApiResource(
|
||||
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.',
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
]
|
||||
)]
|
||||
class Machine
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['document:list'])]
|
||||
@@ -36,11 +55,8 @@ class Machine
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'machines')]
|
||||
#[ORM\JoinColumn(name: 'siteId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Site $site;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TypeMachine::class, inversedBy: 'machines')]
|
||||
#[ORM\JoinColumn(name: 'typeMachineId', referencedColumnName: 'id', nullable: true)]
|
||||
private ?TypeMachine $typeMachine = null;
|
||||
#[Assert\NotNull(message: 'Le site est obligatoire.')]
|
||||
private ?Site $site = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Constructeur>
|
||||
@@ -77,6 +93,12 @@ class Machine
|
||||
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: Document::class)]
|
||||
private Collection $documents;
|
||||
|
||||
/**
|
||||
* @var Collection<int, CustomField>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: CustomField::class, cascade: ['persist', 'remove'])]
|
||||
private Collection $customFields;
|
||||
|
||||
/**
|
||||
* @var Collection<int, CustomFieldValue>
|
||||
*/
|
||||
@@ -91,44 +113,18 @@ class Machine
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
$this->constructeurs = new ArrayCollection();
|
||||
$this->componentLinks = new ArrayCollection();
|
||||
$this->pieceLinks = new ArrayCollection();
|
||||
$this->productLinks = new ArrayCollection();
|
||||
$this->documents = new ArrayCollection();
|
||||
$this->customFields = new ArrayCollection();
|
||||
$this->customFieldValues = new ArrayCollection();
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
@@ -165,26 +161,43 @@ class Machine
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSite(): Site
|
||||
public function getSite(): ?Site
|
||||
{
|
||||
return $this->site;
|
||||
}
|
||||
|
||||
public function setSite(Site $site): static
|
||||
public function setSite(?Site $site): static
|
||||
{
|
||||
$this->site = $site;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeMachine(): ?TypeMachine
|
||||
/**
|
||||
* @return Collection<int, CustomField>
|
||||
*/
|
||||
public function getCustomFields(): Collection
|
||||
{
|
||||
return $this->typeMachine;
|
||||
return $this->customFields;
|
||||
}
|
||||
|
||||
public function setTypeMachine(?TypeMachine $typeMachine): static
|
||||
public function addCustomField(CustomField $customField): static
|
||||
{
|
||||
$this->typeMachine = $typeMachine;
|
||||
if (!$this->customFields->contains($customField)) {
|
||||
$this->customFields->add($customField);
|
||||
$customField->setMachine($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeCustomField(CustomField $customField): static
|
||||
{
|
||||
if ($this->customFields->removeElement($customField)) {
|
||||
if ($customField->getMachine() === $this) {
|
||||
$customField->setMachine(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -197,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>
|
||||
*/
|
||||
@@ -236,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -15,9 +22,21 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
#[ORM\Entity(repositoryClass: MachineComponentLinkRepository::class)]
|
||||
#[ORM\Table(name: 'machine_component_links')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
#[ApiResource(
|
||||
description: 'Liaisons 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')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
]
|
||||
)]
|
||||
class MachineComponentLink
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
@@ -40,10 +59,6 @@ class MachineComponentLink
|
||||
#[ORM\OneToMany(mappedBy: 'parentLink', targetEntity: MachineComponentLink::class)]
|
||||
private Collection $childLinks;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TypeMachineComponentRequirement::class, inversedBy: 'machineComponentLinks')]
|
||||
#[ORM\JoinColumn(name: 'typeMachineComponentRequirementId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?TypeMachineComponentRequirement $typeMachineComponentRequirement = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachinePieceLink>
|
||||
*/
|
||||
@@ -73,41 +88,13 @@ class MachineComponentLink
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->childLinks = new ArrayCollection();
|
||||
$this->pieceLinks = new ArrayCollection();
|
||||
$this->productLinks = new ArrayCollection();
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMachine(): Machine
|
||||
{
|
||||
return $this->machine;
|
||||
@@ -144,18 +131,6 @@ class MachineComponentLink
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeMachineComponentRequirement(): ?TypeMachineComponentRequirement
|
||||
{
|
||||
return $this->typeMachineComponentRequirement;
|
||||
}
|
||||
|
||||
public function setTypeMachineComponentRequirement(?TypeMachineComponentRequirement $requirement): static
|
||||
{
|
||||
$this->typeMachineComponentRequirement = $requirement;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNameOverride(): ?string
|
||||
{
|
||||
return $this->nameOverride;
|
||||
@@ -191,9 +166,4 @@ class MachineComponentLink
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,39 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
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]
|
||||
#[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')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
]
|
||||
)]
|
||||
class MachinePieceLink
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
@@ -34,10 +54,6 @@ class MachinePieceLink
|
||||
#[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?MachineComponentLink $parentLink = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TypeMachinePieceRequirement::class, inversedBy: 'machinePieceLinks')]
|
||||
#[ORM\JoinColumn(name: 'typeMachinePieceRequirementId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?TypeMachinePieceRequirement $typeMachinePieceRequirement = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachineProductLink>
|
||||
*/
|
||||
@@ -53,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;
|
||||
|
||||
@@ -61,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;
|
||||
@@ -130,18 +122,6 @@ class MachinePieceLink
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeMachinePieceRequirement(): ?TypeMachinePieceRequirement
|
||||
{
|
||||
return $this->typeMachinePieceRequirement;
|
||||
}
|
||||
|
||||
public function setTypeMachinePieceRequirement(?TypeMachinePieceRequirement $requirement): static
|
||||
{
|
||||
$this->typeMachinePieceRequirement = $requirement;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getNameOverride(): ?string
|
||||
{
|
||||
return $this->nameOverride;
|
||||
@@ -178,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@ declare(strict_types=1);
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Repository\MachineProductLinkRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -15,9 +22,21 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
#[ORM\Entity(repositoryClass: MachineProductLinkRepository::class)]
|
||||
#[ORM\Table(name: 'machine_product_links')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource]
|
||||
#[ApiResource(
|
||||
description: 'Liaisons 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')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
]
|
||||
)]
|
||||
class MachineProductLink
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
@@ -30,10 +49,6 @@ class MachineProductLink
|
||||
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Product $product;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: TypeMachineProductRequirement::class, inversedBy: 'machineProductLinks')]
|
||||
#[ORM\JoinColumn(name: 'typeMachineProductRequirementId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?TypeMachineProductRequirement $typeMachineProductRequirement = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MachineProductLink::class, inversedBy: 'childLinks')]
|
||||
#[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?MachineProductLink $parentLink = null;
|
||||
@@ -60,39 +75,11 @@ class MachineProductLink
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->childLinks = new ArrayCollection();
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMachine(): Machine
|
||||
{
|
||||
return $this->machine;
|
||||
@@ -117,18 +104,6 @@ class MachineProductLink
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeMachineProductRequirement(): ?TypeMachineProductRequirement
|
||||
{
|
||||
return $this->typeMachineProductRequirement;
|
||||
}
|
||||
|
||||
public function setTypeMachineProductRequirement(?TypeMachineProductRequirement $requirement): static
|
||||
{
|
||||
$this->typeMachineProductRequirement = $requirement;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getParentLink(): ?MachineProductLink
|
||||
{
|
||||
return $this->parentLink;
|
||||
@@ -164,9 +139,4 @@ class MachineProductLink
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,22 @@ use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use App\State\ModelTypeProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ModelTypeRepository::class)]
|
||||
#[ORM\Table(name: 'model_types')]
|
||||
@@ -24,18 +32,29 @@ use Symfony\Component\Serializer\Annotation\Groups;
|
||||
#[ApiFilter(SearchFilter::class, properties: ['category' => 'exact', 'name' => 'ipartial'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
|
||||
#[ApiResource(
|
||||
description: 'Types et catégories. Référentiel de classification pour les machines, pièces, composants et produits. Chaque type appartient à une catégorie (machine, piece, composant, product) et peut être converti.',
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')", 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,
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class ModelType
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['type_machine:read', 'model_type:read'])]
|
||||
#[Groups(['type_machine:read', 'model_type:read', 'product:read', 'composant:read', 'piece:read'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 120)]
|
||||
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])]
|
||||
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write', 'product:read', 'composant:read', 'piece:read'])]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 60, unique: true)]
|
||||
@@ -54,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'])]
|
||||
private ?array $componentSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'pieceSkeleton')]
|
||||
#[Groups(['model_type:read'])]
|
||||
private ?array $pieceSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'productSkeleton')]
|
||||
#[Groups(['model_type:read'])]
|
||||
private ?array $productSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['model_type:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -94,24 +101,6 @@ class ModelType
|
||||
#[ORM\OneToMany(mappedBy: 'typeProduct', targetEntity: Product::class)]
|
||||
private Collection $products;
|
||||
|
||||
/**
|
||||
* @var Collection<int, TypeMachineComponentRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'typeComposant', targetEntity: TypeMachineComponentRequirement::class)]
|
||||
private Collection $componentRequirements;
|
||||
|
||||
/**
|
||||
* @var Collection<int, TypeMachinePieceRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'typePiece', targetEntity: TypeMachinePieceRequirement::class)]
|
||||
private Collection $pieceRequirements;
|
||||
|
||||
/**
|
||||
* @var Collection<int, TypeMachineProductRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'typeProduct', targetEntity: TypeMachineProductRequirement::class)]
|
||||
private Collection $productRequirements;
|
||||
|
||||
/**
|
||||
* @var Collection<int, CustomField>
|
||||
*/
|
||||
@@ -130,47 +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->componentRequirements = new ArrayCollection();
|
||||
$this->pieceRequirements = new ArrayCollection();
|
||||
$this->productRequirements = new ArrayCollection();
|
||||
$this->customFields = new ArrayCollection();
|
||||
$this->pieceCustomFields = new ArrayCollection();
|
||||
$this->productCustomFields = new ArrayCollection();
|
||||
}
|
||||
|
||||
#[ORM\PrePersist]
|
||||
public function setCreatedAtValue(): void
|
||||
{
|
||||
$now = new DateTimeImmutable();
|
||||
$this->createdAt = $now;
|
||||
$this->updatedAt = $now;
|
||||
|
||||
if (null === $this->id) {
|
||||
$this->id = $this->generateCuid();
|
||||
}
|
||||
}
|
||||
|
||||
#[ORM\PreUpdate]
|
||||
public function setUpdatedAtValue(): void
|
||||
{
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getId(): ?string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $id): static
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->composants = new ArrayCollection();
|
||||
$this->pieces = new ArrayCollection();
|
||||
$this->products = new ArrayCollection();
|
||||
$this->customFields = new ArrayCollection();
|
||||
$this->pieceCustomFields = new ArrayCollection();
|
||||
$this->productCustomFields = new ArrayCollection();
|
||||
$this->skeletonPieceRequirements = new ArrayCollection();
|
||||
$this->skeletonProductRequirements = new ArrayCollection();
|
||||
$this->skeletonSubcomponentRequirements = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
@@ -206,11 +188,6 @@ class ModelType
|
||||
{
|
||||
$this->category = $category;
|
||||
|
||||
if (null !== $this->pendingStructure) {
|
||||
$this->applyStructureForCategory($this->pendingStructure, $category);
|
||||
$this->pendingStructure = null;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -238,101 +215,192 @@ 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'])]
|
||||
#[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 getCreatedAt(): DateTimeImmutable
|
||||
public function getPendingStructure(): ?array
|
||||
{
|
||||
return $this->createdAt;
|
||||
return $this->pendingStructure;
|
||||
}
|
||||
|
||||
public function getUpdatedAt(): DateTimeImmutable
|
||||
public function clearPendingStructure(): void
|
||||
{
|
||||
return $this->updatedAt;
|
||||
$this->pendingStructure = null;
|
||||
}
|
||||
|
||||
private function generateCuid(): string
|
||||
/**
|
||||
* @return Collection<int, CustomField>
|
||||
*/
|
||||
public function getComponentCustomFields(): Collection
|
||||
{
|
||||
return 'cl'.bin2hex(random_bytes(12));
|
||||
return $this->customFields;
|
||||
}
|
||||
|
||||
private function applyStructureForCategory(?array $structure, ModelCategory $category): void
|
||||
/**
|
||||
* @return Collection<int, CustomField>
|
||||
*/
|
||||
public function getPieceCustomFields(): Collection
|
||||
{
|
||||
if (ModelCategory::COMPONENT === $category) {
|
||||
$this->componentSkeleton = $structure;
|
||||
$this->pieceSkeleton = null;
|
||||
$this->productSkeleton = null;
|
||||
return $this->pieceCustomFields;
|
||||
}
|
||||
|
||||
return;
|
||||
/**
|
||||
* @return Collection<int, CustomField>
|
||||
*/
|
||||
public function getProductCustomFields(): Collection
|
||||
{
|
||||
return $this->productCustomFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SkeletonPieceRequirement>
|
||||
*/
|
||||
public function getSkeletonPieceRequirements(): Collection
|
||||
{
|
||||
return $this->skeletonPieceRequirements;
|
||||
}
|
||||
|
||||
public function addSkeletonPieceRequirement(SkeletonPieceRequirement $requirement): static
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,39 +8,63 @@ use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Repository\PieceRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[UniqueEntity(fields: ['reference'], message: 'Une pièce avec cette référence existe déjà.')]
|
||||
#[ORM\Entity(repositoryClass: PieceRepository::class)]
|
||||
#[ORM\Table(name: 'pieces')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact', 'typePiece.name' => 'ipartial'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
|
||||
#[ApiResource(
|
||||
description: 'Pièces détachées du catalogue. Une pièce peut être rattachée à plusieurs machines et possède un type, des fournisseurs, des documents et un produit associé.',
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['piece:read']],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class Piece
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['piece:read', 'document:list'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
#[Groups(['piece:read', 'document:list'])]
|
||||
private string $name;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true, nullable: true)]
|
||||
#[Groups(['piece:read'])]
|
||||
private ?string $reference = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['piece:read'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
|
||||
#[Groups(['piece:read'])]
|
||||
private ?string $prix = null;
|
||||
@@ -55,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>
|
||||
*/
|
||||
@@ -85,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;
|
||||
@@ -101,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;
|
||||
@@ -161,6 +175,18 @@ class Piece
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): static
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPrix(): ?string
|
||||
{
|
||||
return $this->prix;
|
||||
@@ -194,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;
|
||||
@@ -209,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,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;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@ use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Repository\ProductRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
@@ -19,15 +26,26 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ORM\Entity(repositoryClass: ProductRepository::class)]
|
||||
#[ORM\Table(name: 'products')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact', 'typeProduct.name' => 'ipartial'])]
|
||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt', 'supplierPrice'])]
|
||||
#[ApiResource(
|
||||
description: 'Produits du catalogue fournisseur. Un produit possède une référence, un prix indicatif, un type, des fournisseurs et des documents. Il peut être lié à des machines.',
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['product:read']],
|
||||
paginationClientItemsPerPage: true,
|
||||
paginationMaximumItemsPerPage: 200
|
||||
)]
|
||||
class Product
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
#[Groups(['product:read', 'document:list'])]
|
||||
@@ -88,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;
|
||||
@@ -104,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;
|
||||
@@ -230,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,16 @@ use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Repository\ProfileRepository;
|
||||
use App\State\ProfilePasswordHasher;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ProfileRepository::class)]
|
||||
@@ -23,12 +25,26 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\UniqueConstraint(name: 'UNIQ_email', columns: ['email'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[ApiResource(
|
||||
description: 'Profils utilisateurs. Chaque profil possède un rôle (Admin, Gestionnaire, Viewer, User), un email unique et un mot de passe. Gère l\'authentification par session.',
|
||||
operations: [
|
||||
new Get(),
|
||||
new GetCollection(),
|
||||
new Post(),
|
||||
new Put(),
|
||||
new Delete(),
|
||||
new Get(security: "is_granted('ROLE_ADMIN')"),
|
||||
new GetCollection(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
denormalizationContext: ['groups' => ['profile:write', 'profile:admin:write']],
|
||||
processor: ProfilePasswordHasher::class,
|
||||
),
|
||||
new Put(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
denormalizationContext: ['groups' => ['profile:write', 'profile:admin:write']],
|
||||
processor: ProfilePasswordHasher::class,
|
||||
),
|
||||
new Patch(
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
denormalizationContext: ['groups' => ['profile:write', 'profile:admin:write']],
|
||||
processor: ProfilePasswordHasher::class,
|
||||
),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['profile:read']],
|
||||
denormalizationContext: ['groups' => ['profile:write']]
|
||||
@@ -63,16 +79,21 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
* @var list<string> The user roles
|
||||
*/
|
||||
#[ORM\Column(type: 'json', options: ['default' => '["ROLE_USER"]'])]
|
||||
#[Groups(['profile:read', 'profile:write'])]
|
||||
#[Groups(['profile:read', 'profile:admin:write'])]
|
||||
private array $roles = ['ROLE_USER'];
|
||||
|
||||
/**
|
||||
* @var string The hashed password
|
||||
* @var null|string The hashed password
|
||||
*/
|
||||
#[ORM\Column(type: 'string', nullable: true)]
|
||||
#[Groups(['profile:write'])]
|
||||
private ?string $password = null;
|
||||
|
||||
/**
|
||||
* Non-persisted field used for password hashing via ProfilePasswordHasher.
|
||||
*/
|
||||
#[Groups(['profile:write'])]
|
||||
private ?string $plainPassword = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable', name: 'createdat')]
|
||||
#[Groups(['profile:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -83,8 +104,7 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// Générer un CUID-like ID pour compatibilité avec Prisma
|
||||
$this->id = 'cl'.substr(strtolower(base_convert(random_bytes(12), 2, 36)), 0, 24);
|
||||
$this->id = 'cl'.bin2hex(random_bytes(12));
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
@@ -157,11 +177,10 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
*/
|
||||
public function getRoles(): array
|
||||
{
|
||||
$roles = $this->roles;
|
||||
// guarantee every user at least has ROLE_USER
|
||||
$roles = $this->roles;
|
||||
$roles[] = 'ROLE_USER';
|
||||
|
||||
return array_unique($roles);
|
||||
return array_values(array_unique($roles));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,20 +201,37 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function setPassword(string $password): static
|
||||
public function setPassword(?string $password): static
|
||||
{
|
||||
$this->password = $password;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPlainPassword(): ?string
|
||||
{
|
||||
return $this->plainPassword;
|
||||
}
|
||||
|
||||
public function setPlainPassword(?string $plainPassword): static
|
||||
{
|
||||
$this->plainPassword = $plainPassword;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['profile:read'])]
|
||||
public function getHasPassword(): bool
|
||||
{
|
||||
return null !== $this->password && '' !== $this->password;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see UserInterface
|
||||
*/
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
// If you store any temporary, sensitive data on the user, clear it here
|
||||
// $this->plainPassword = null;
|
||||
$this->plainPassword = null;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user