Compare commits

...

61 Commits

Author SHA1 Message Date
Matthieu
b2aff0e414 feat(sync) : add slot selection controllers, custom field sync, and position fallbacks
- Add selectedPieceId support to ComposantPieceSlotController
- Create ComposantProductSlotController and ComposantSubcomponentSlotController
- Add updateCustomFields() to SkeletonStructureService for managing CustomField entities
- Fix position/orderIndex fallback to array index in all 3 sync strategies
- Fix type comparison in ProductSyncStrategy for dual format support
- Update CLAUDE.md with new entities, controllers, and fixtures documentation
- Update frontend submodule with interactive slot selectors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:40:44 +01:00
Matthieu
4072abf7ba feat(sync) : add ModelTypeSyncService orchestrator and controller with tests
Implement the sync orchestrator that delegates to tagged strategies via
AutowireIterator, and the HTTP controller exposing sync-preview and sync
endpoints with transaction wrapping and role-based access control.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:17:57 +01:00
Matthieu
089ca43404 feat(sync) : implement PieceSyncStrategy with tests 2026-03-13 14:07:04 +01:00
Matthieu
f09c7e4782 feat(sync) : implement ComposantSyncStrategy with tests 2026-03-13 14:00:59 +01:00
Matthieu
6a20dcce54 feat(sync) : implement ProductSyncStrategy with tests 2026-03-13 13:54:47 +01:00
Matthieu
6e0be3dbf3 feat(sync) : add DTOs and SyncStrategyInterface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:47:59 +01:00
Matthieu
f66db3f2f0 test(sync) : extend factories for PieceProductSlot and CustomField with ModelType
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:44:52 +01:00
Matthieu
0864af1439 feat(sync) : migration for PieceProductSlot table and version columns
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:41:56 +01:00
Matthieu
5210e53d73 feat(sync) : add version field and PieceProductSlot entity
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:38:13 +01:00
Matthieu
3f07162b94 docs(sync) : add implementation plan for ModelType sync
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:16:19 +01:00
Matthieu
57615b3e9d docs(sync) : address spec review feedback — atomicity, matching, M2M migration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:02:27 +01:00
Matthieu
46694d11d9 docs(sync) : add ModelType sync design spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:57:27 +01:00
Matthieu
44cfa25eca feat(composant) : add ComposantPieceSlotController for slot quantity PATCH
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:22:43 +01:00
Matthieu
7ea4cc8c12 chore(submodule) : update frontend pointer
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:22:01 +01:00
Matthieu
bb300a7ca7 feat(composant) : add virtual getStructure() rebuilding legacy JSON from slot tables
Exposes structure as a computed property from pieceSlots, productSlots,
and subcomponentSlots relations, including slotId for frontend quantity
persistence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:21:17 +01:00
Matthieu
556da6e451 fix(custom-fields) : include customFields and customFieldValues in product normalization
normalizeProduct() had customFields hardcoded to [] and was missing
customFieldValues entirely, unlike normalizeComposant and normalizePiece.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:20:28 +01:00
Matthieu
8871440c9a fix(custom-fields) : populate customFields in ModelType structure from relations
getStructure() was returning hardcoded empty customFields arrays after
the JSON-to-tables migration. Now reads from the relational CustomField
entities via serializeCustomFields() for all three categories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:19:42 +01:00
Matthieu
6f1756e82e fix(fixtures) : add INSERT statements for new relational tables
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:31:02 +01:00
Matthieu
55bed90ac7 test(normalization) : add tests for skeleton requirements, composant slots, piece-product relation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:25:00 +01:00
Matthieu
a6139d7090 feat(normalization) : drop structure and productIds JSON columns
- Remove Composant.structure property, getter/setter
- Remove Piece.productIds property, setProductIds()
- Update fixtures to remove dropped columns
- Add migrations to drop both columns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:19:54 +01:00
Matthieu
8ed5f90b63 feat(structure) : read composant structure from slot relations instead of JSON
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:23:56 +01:00
Matthieu
5194543d16 feat(composant) : create composant slot tables and migrate data from structure JSON
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:20:31 +01:00
Matthieu
c01b71fe06 feat(composant) : add composant slot entities for structure normalization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:15:42 +01:00
Matthieu
5336dfc09d feat(skeleton) : drop skeleton JSON columns from model_types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:11:14 +01:00
Matthieu
77c5d25cea feat(skeleton) : wire skeleton writes to SkeletonStructureService
ModelType.setStructure() now stores data in pendingStructure instead of
writing to JSON columns. A new ModelTypeProcessor intercepts API Platform
POST/PUT/PATCH operations and delegates skeleton writes to
SkeletonStructureService, which persists to relation tables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 18:03:32 +01:00
Matthieu
e2326064ba feat(skeleton) : expose skeleton relations via API and create SkeletonStructureService
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:58:18 +01:00
Matthieu
100e24725c feat(skeleton) : create skeleton requirement tables and migrate data from JSON
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:54:14 +01:00
Matthieu
515bae189e feat(skeleton) : add skeleton requirement entities for ModelType
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:48:39 +01:00
Matthieu
333f2a88af feat(machine) : exposer structure composant + pièces résolues dans la vue machine
- normalizeComposant : inclure structure du composant dans la réponse
- enrichStructureWithPieceData : résoudre selectedPieceId vers les
  données complètes de la pièce catalogue (nom, référence, prix, etc.)
- Update submodule : affichage pièces incluses + quantité machine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:23:55 +01:00
Matthieu
eccbc1bd56 chore(frontend) : update submodule — fix quantity save on composant edit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 15:02:55 +01:00
Matthieu
2a0809a065 chore(frontend) : update submodule — quantity on composant edit page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:41:12 +01:00
Matthieu
f2061abce8 chore(frontend) : update submodule — piece quantity display
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 14:33:04 +01:00
Matthieu
42c7072bcd chore(frontend) : update submodule — piece quantity feature
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:14:09 +01:00
Matthieu
1f90f809ac test(piece) : add quantity tests for MachinePieceLink
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:06:23 +01:00
Matthieu
a940f53f8a feat(piece) : add quantity to structure normalization, PATCH and clone
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:04:03 +01:00
Matthieu
c74bdedf9b feat(piece) : add quantity field to MachinePieceLink entity + migration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:01:42 +01:00
Matthieu
233ee3faf3 docs : add piece quantity implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:56:24 +01:00
Matthieu
b8edf1ea95 docs : update piece quantity spec after review
Address review findings: drop Groups attribute, add clone logic,
specify PATCH payload format, list frontend functions to update,
add validation and test cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:48:01 +01:00
Matthieu
7a7af58074 docs : add piece quantity design spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:44:34 +01:00
Matthieu
03e6c2432b fix(machine) : add addConstructeur/removeConstructeur methods + fix fournisseur display
API Platform silently ignored the constructeurs field on PATCH because
Machine was missing the add/remove methods (unlike Composant, Piece, Product).
Also fixes the read-only fournisseur display overflow in MachineInfoCard.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:39:03 +01:00
bab13e5c57 chore : clean project config — untrack .idea/, gitignore Zone.Identifier and frontend/, blank JWT secret
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 02:01:54 +01:00
Matthieu
378026ebce chore(frontend) : update submodule — add buttons repositioned
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:32:56 +01:00
Matthieu
ea2b813728 chore(frontend) : update submodule — product delete confirmation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:07:06 +01:00
Matthieu
20653b9046 docs(changelog) : add delete confirmation dialog entry
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:59:48 +01:00
Matthieu
c6deef6028 chore(frontend) : update submodule — delete confirmation dialog
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 10:59:10 +01:00
Matthieu
e922b14419 feat(api) : add /api/health endpoint for monitoring
- Returns status, version, timestamp, PHP version, DB latency and memory usage
- Accessible without authentication (PUBLIC_ACCESS)
- Returns 200 when healthy, 503 when degraded (DB down)
2026-03-06 09:51:09 +01:00
Matthieu
d16b042739 chore(frontend) : update submodule — changelog v1.8.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:39:07 +01:00
Matthieu
2b3c1fe08e docs(changelog) : complete v1.8.1 changelog with all frontend changes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:36:37 +01:00
134 changed files with 16092 additions and 3802 deletions

7
.env
View File

@@ -16,7 +16,7 @@
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=
APP_SECRET=change_me_in_env_local
APP_SHARE_DIR=var/share
###< symfony/framework-bundle ###
@@ -40,8 +40,3 @@ DEFAULT_URI=http://localhost
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###
###> lexik/jwt-authentication-bundle ###
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=281e2cd303ed9ba4a4a4074e19eac9cea505cc9d82ce79a448bb8eb00c636ebe
###< lexik/jwt-authentication-bundle ###

13
.gitignore vendored
View File

@@ -39,8 +39,13 @@ FEATURE_IDEAS.md
###< temp files ###
###> frontend ###
/frontend/node_modules/
/frontend/.nuxt/
/frontend/.output/
/frontend/dist/
/frontend/
###< frontend ###
###> ide ###
/.idea/
###< ide ###
###> wsl ###
*:Zone.Identifier
###< wsl ###

9
.idea/.gitignore generated vendored
View File

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

144
.idea/Inventory.iml generated
View File

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

View File

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

12
.idea/dataSources.xml generated
View File

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

View File

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

View File

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

8
.idea/modules.xml generated
View File

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

160
.idea/php.xml generated
View File

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

6
.idea/symfony2.xml generated
View File

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

6
.idea/vcs.xml generated
View File

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

6
.idea/workspace.xml generated
View File

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

View File

@@ -2,12 +2,22 @@
## [1.8.1] - 2026-03-05
### Ajouts
- **Composant DataTable generique** : nouveau composant `DataTable.vue` + composable `useDataTable.ts` avec tri, recherche, pagination et filtres server-side. Toutes les pages catalogue (composants, pieces, produits, documents, constructeurs, commentaires, journal d'audit, admin) migrees vers ce composant partage.
- **Messages d'erreur humanises** : les erreurs backend (violations de contraintes, erreurs serveur) sont desormais traduites en messages comprehensibles pour l'utilisateur final (`errorMessages.ts`).
- **Icones Lucide dans la navbar** : reorganisation des groupes de navigation et ajout d'icones pour chaque section.
- **Modal d'ajout d'entites aux machines** (`AddEntityToMachineModal.vue`) : ajout direct de composants, pieces et produits depuis la fiche machine.
- **Filtres SearchFilter ipartial** sur les noms de types de modeles et commentaires cote API.
### Refactoring
- **Suppression du systeme TypeMachine (squelettes machines)** : les entites `TypeMachine`, `TypeMachineComponentRequirement`, `TypeMachinePieceRequirement`, `TypeMachineProductRequirement` sont supprimees. Les champs personnalises machines sont desormais lies directement a chaque machine (relation `CustomField → Machine`).
- **Suppression des pages squelettes machines** : pages `/machine-skeleton`, `/type/[id]`, `/type/edit/[id]` et tous les composants associes (`TypeEditForm`, `MachineSkeletonSummary`, `MachineCreatePreview`, selectors de requirements).
- **Suppression du systeme TypeMachine (squelettes machines)** : les entites `TypeMachine`, `TypeMachineComponentRequirement`, `TypeMachinePieceRequirement`, `TypeMachineProductRequirement` sont supprimees avec leurs repositories et state processors. Les champs personnalises machines sont desormais lies directement a chaque machine (relation `CustomField → Machine`).
- **Suppression des pages squelettes machines** : pages `/machine-skeleton`, `/type/[id]`, `/type/edit/[id]` et tous les composants associes (`TypeEditForm`, `MachineSkeletonSummary`, `MachineCreatePreview`, selectors de requirements, `useMachineTypesApi`, `useMachineSkeletonEditor`, `useMachineCreateSelections`, `useMachineCreatePreview`).
- **Simplification de la creation de machines** : plus besoin de selectionner un squelette, ajout direct de composants/pieces/produits.
- **Refactoring MachineStructureController** : remplacement de `MachineSkeletonController` par `MachineStructureController` avec gestion directe de la structure machine.
- **Migration de toutes les tables vers DataTable** : suppression du code de tableau duplique dans chaque page au profit du composant generique.
### Corrections
- **Suppression catalogue avec confirmation** : la suppression d'une piece ou d'un composant dans le catalogue affiche desormais une modale de confirmation listant les elements qui seront supprimes en cascade (documents, liaisons machine, valeurs de champs personnalises) au lieu de bloquer la suppression.
- **Fix affichage categorie sur les pages edit** : les categories (produit, composant, piece) s'affichent correctement sur les pages d'edition au lieu de "Categorie inconnue". Cause : import `Serializer\Annotation\Groups` obsolete dans `ModelType` (remplace par `Attribute\Groups` pour Symfony 8) + groupes de serialisation manquants (`product:read`, `composant:read`, `piece:read`).
- Fix import `Serializer\Annotation\Groups``Attribute\Groups` dans `Profile`.
- Fix filtre `SearchFilter` : `partial``ipartial` sur `Comment.entityName` et `Document.name`/`Document.filename` pour recherche insensible a la casse.

View File

@@ -49,13 +49,14 @@ Inventory/ # Backend Symfony (repo principal)
# Docker
make start # Démarrer les containers
make stop # Arrêter
make shell # Shell dans le container PHP
make shell # Shell interactif (nécessite un TTY)
make install # Install complet (composer + npm + build)
# Backend
make test # PHPUnit
docker compose exec php vendor/bin/php-cs-fixer fix # Linter PHP
docker compose exec php php bin/console doctrine:migrations:migrate
make test # PHPUnit (tous les tests)
make test FILES=tests/Api/Entity/MachineTest.php # Un test spécifique
make php-cs-fixer-allow-risky # Linter PHP (cs-fixer)
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate
# Frontend (dans Inventory_frontend/)
npm run dev # Dev server (port 3001)
@@ -63,6 +64,14 @@ npm run build # Build production
npm run lint:fix # ESLint fix
npx nuxi typecheck # TypeScript check (0 errors attendu)
# Database / Fixtures
make db-reset # Reset database (drop + recreate schema)
make fixtures-dump # Dump la DB vers fixtures/data.sql
make fixtures-load # Charger les fixtures SQL (désactive FK)
make fixtures-reset # Reset DB + recharger fixtures
make import-data # Importer les dumps SQL normalisés
make cache-clear # Clear cache Symfony
# Release
./scripts/release.sh patch # Bump patch version (ou minor/major)
```
@@ -98,7 +107,12 @@ Le frontend est un submodule git. Lors d'un commit frontend :
## Architecture Backend
### Entités Principales
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `TypeMachine`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
#### Entités de normalisation (slots & skeleton requirements)
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
- **Slots** (données réelles d'un composant) : `ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot`
- **Skeleton Requirements** (définitions du ModelType) : `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
### Patterns
- **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
@@ -108,6 +122,33 @@ Le frontend est un submodule git. Lors d'un commit frontend :
- **Audit** : Subscribers Doctrine `onFlush` capturent diff + snapshot complet
- **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence
### Custom Controllers (pas API Platform)
- `MachineStructureController``/api/machines/{id}/structure` (GET/PATCH), `/api/machines/{id}/clone` (POST) : hiérarchie complète machine avec normalisation JSON manuelle. Source principale de données pour la page détail machine.
- `MachineCustomFieldsController``/api/machines/{id}/add-custom-fields` (POST) : initialise les CustomFieldValue manquants pour une machine.
- `CustomFieldValueController``/api/custom-fields/values/*` : CRUD + upsert pour les valeurs de champs perso.
- `ComposantPieceSlotController``/api/composant-piece-slots/{id}` (PATCH) : mise à jour des slots pièce d'un composant.
- `SessionProfileController``/api/session/profile` (GET/POST/DELETE) : auth session (login/logout/current user).
- `SessionProfilesController``/api/session/profiles` (GET) : liste des profils disponibles pour la session.
- `AdminProfileController``/api/admin/profiles` : CRUD profils, gestion rôles et mots de passe (ROLE_ADMIN).
- `CommentController``/api/comments` : création, résolution, compteur non-résolus.
- `ActivityLogController``/api/activity-logs` (GET) : journal d'activité global.
- `EntityHistoryController``/api/{entity}/{id}/history` (GET) : historique audit par entité (machines, pièces, composants, produits).
- `DocumentQueryController``/api/documents/{entity}/{id}` (GET) : documents par site/machine/composant/pièce/produit.
- `DocumentServeController``/api/documents/{id}/file|download` (GET) : servir/télécharger fichiers.
- `ModelTypeConversionController``/api/model_types/{id}/conversion-check|convert` : vérification et conversion de ModelType.
- `HealthCheckController``/api/health` (GET) : health check.
### Custom Fields — Architecture
- **Composants/Pièces/Produits** : définitions dans les entités `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` du ModelType (anciennement JSON `structure`, normalisé en tables relationnelles). Les custom fields de ces entités sont définis dans `customFields` JSON sur chaque Skeleton*Requirement.
- **Machines** : définitions = entités `CustomField` liées directement via `machineId` FK (pas de ModelType)
- Les deux partagent la même entité `CustomFieldValue` pour stocker les valeurs
### Normalisation JSON → Tables (architecture slots)
Les anciennes colonnes JSON `structure` et `productIds` des Composants ont été remplacées par des tables relationnelles :
- **ModelType** définit le squelette via `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
- **Composant** stocke les données réelles via `ComposantPieceSlot`, `ComposantProductSlot`, `ComposantSubcomponentSlot`
- Chaque slot référence son skeleton requirement (`skeletonRequirement` FK) + l'entité sélectionnée + position
### Rôles (hiérarchie)
```
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
@@ -138,13 +179,18 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
## Règles Importantes
### CLAUDE.md — Maintenance obligatoire
- **Toujours consulter** ce fichier en début de conversation pour respecter les conventions
- **Mettre à jour** ce fichier quand une nouvelle convention, pattern ou décision architecturale est établie
- **Utiliser comme source de vérité** pour les commandes, patterns et règles du projet
### Toujours faire AVANT de modifier du code
1. **Lire le fichier** avant de l'éditer — ne jamais proposer de changements sur du code non lu
2. **Comprendre le pattern existant** — reproduire le style du fichier (noms, indentation, structure)
3. **Vérifier les deux repos** — un changement peut impacter backend ET frontend
### Après chaque modification
1. Backend PHP : `docker compose exec php vendor/bin/php-cs-fixer fix`
1. Backend PHP : `make php-cs-fixer-allow-risky`
2. Frontend : `npm run lint:fix` puis `npx nuxi typecheck` si fichiers TS modifiés
### Ne jamais faire
@@ -161,6 +207,25 @@ Quand les branches `master` et `develop` divergent sur l'un des deux repos, **to
- Main repo : `git checkout master && git merge develop && git push`
- Frontend : `git checkout develop && git merge master && git push` (ou l'inverse selon le cas)
## Tests
### Stack de test
- **PHPUnit 12** + **API Platform Test** (`ApiTestCase`)
- **DAMA DoctrineTestBundle** — wrappe chaque test dans une transaction avec rollback automatique (pas de TRUNCATE)
- Base de test : même PG, env `test`
### Commandes
Voir section "Key Commands". Commande additionnelle :
```bash
make test-setup # Créer/mettre à jour le schéma test
```
### Pattern de test
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
## URLs Locales
- API Symfony : `http://localhost:8081/api`
- Nuxt dev : `http://localhost:3001`

214
DEPLOY.md
View File

@@ -1,17 +1,29 @@
# Inventory - Guide de Déploiement & Release
# Inventory Guide de Déploiement
## Architecture
Guide pour déployer l'application sur un serveur de production.
## Architecture de production
```
inventory.malio-dev.fr/ → Frontend Nuxt (statique)
inventory.malio-dev.fr/api → Backend Symfony (PHP-FPM)
inventory.malio-dev.fr/ → Frontend Nuxt (fichiers statiques servis par Nginx)
inventory.malio-dev.fr/api → Backend Symfony (PHP-FPM derrière Nginx)
```
| Composant | Technologie | Emplacement serveur |
|-----------|-------------|---------------------|
| Backend | Symfony 8 + API Platform | `/var/www/Inventory/` |
| Frontend | Nuxt 4 (statique) | `/var/www/Inventory/Inventory_frontend/.output/public/` |
| Base de données | PostgreSQL 16 | `inventory` |
| Frontend | Nuxt 4 (site statique) | `/var/www/Inventory/Inventory_frontend/.output/public/` |
| Base de données | PostgreSQL 16 | Base `inventory` |
### Schéma simplifié
```
Navigateur
↓ HTTPS
Nginx (reverse proxy)
├── /api/* → PHP-FPM (Symfony) → PostgreSQL
└── /* → Fichiers statiques (Nuxt build)
```
---
@@ -24,7 +36,8 @@ inventory.malio-dev.fr/api → Backend Symfony (PHP-FPM)
- **PostgreSQL** : 16
- **Composer**
Vérifier :
### Vérification des prérequis
```bash
php -v # PHP 8.4+
php -m | grep -E 'pgsql|intl|zip|gd|mbstring'
@@ -73,10 +86,10 @@ psql -U ferme_user -h 127.0.0.1 -d inventory -f /tmp/backup_v1.0.0_clean.sql
```bash
cd /var/www/Inventory
# Installer les dépendances
# Installer les dépendances (sans les outils de dev)
composer install --no-dev --optimize-autoloader
# Créer .env.local
# Créer le fichier de configuration locale
cat > .env.local << 'EOF'
APP_ENV=prod
APP_DEBUG=0
@@ -85,28 +98,20 @@ APP_SECRET=CHANGE_ME
DATABASE_URL="postgresql://ferme_user:fermerecette@127.0.0.1:5432/inventory?serverVersion=16"
CORS_ALLOW_ORIGIN='^https?://inventory\.malio-dev\.fr$'
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=inventoryjwt
EOF
# Générer APP_SECRET
# Générer un secret aléatoire
sed -i "s/CHANGE_ME/$(openssl rand -hex 32)/" .env.local
# Générer les clés JWT
mkdir -p config/jwt
openssl genrsa -out config/jwt/private.pem -aes256 4096
# Passphrase : inventoryjwt
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
chmod 600 config/jwt/private.pem
# Permissions
# Permissions pour le dossier var/ (cache, logs)
sudo chown -R www-data:www-data var/
sudo chmod -R 775 var/
# Vider le cache
php bin/console cache:clear --env=prod
# Appliquer les migrations (si première installation ou mise à jour)
php bin/console doctrine:migrations:migrate --no-interaction
```
### 4. Configurer le frontend Nuxt
@@ -120,7 +125,7 @@ sudo chown -R malio:malio .
# Installer les dépendances
npm install
# Créer .env
# Créer le fichier d'environnement
cat > .env << 'EOF'
NUXT_PUBLIC_API_BASE_URL=http://inventory.malio-dev.fr/api
EOF
@@ -141,7 +146,7 @@ server {
listen 80;
server_name inventory.malio-dev.fr;
# Gros fichiers (100MB max)
# Gros fichiers (100MB max pour les uploads de documents)
client_max_body_size 100M;
client_body_timeout 300s;
send_timeout 300s;
@@ -149,12 +154,13 @@ server {
access_log /var/log/nginx/inventory-access.log;
error_log /var/log/nginx/inventory-error.log;
# Backend Symfony - /api
# Backend Symfony — toutes les requêtes /api
location /api {
root /var/www/Inventory/public;
try_files $uri /index.php$is_args$args;
}
# PHP-FPM (exécute le code PHP)
location ~ ^/index\.php(/|$) {
fastcgi_pass unix:/run/php/php-fpm.sock;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
@@ -165,27 +171,27 @@ server {
internal;
}
# Frontend statique
# Frontend statique — tout le reste
location / {
root /var/www/Inventory/Inventory_frontend/.output/public;
index index.html;
try_files $uri $uri/ /index.html;
try_files $uri $uri/ /index.html; # SPA fallback
}
}
```
Activer :
Activer le site :
```bash
sudo ln -s /etc/nginx/sites-available/inventory /etc/nginx/sites-enabled/
sudo nginx -t
sudo nginx -t # Vérifier la syntaxe
sudo systemctl reload nginx
```
### 6. Vérifier
```bash
curl http://inventory.malio-dev.fr
curl http://inventory.malio-dev.fr/api
curl http://inventory.malio-dev.fr # Frontend
curl http://inventory.malio-dev.fr/api # API (doc Swagger)
```
---
@@ -197,12 +203,13 @@ curl http://inventory.malio-dev.fr/api
```bash
cd /var/www/Inventory
# Pull les changements
# Récupérer les changements
git pull
git submodule update --init --recursive
# Backend
composer install --no-dev --optimize-autoloader
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console cache:clear --env=prod
sudo chown -R www-data:www-data var/
@@ -214,56 +221,76 @@ npx nuxi generate
---
## Versioning & Releases
## Backup base de données
### Source de vérité
Le fichier `VERSION` à la racine contient le numéro de version (ex: `1.0.0`).
Cette version est synchronisée avec :
- Le footer de l'application
- `config/packages/api_platform.yaml`
### Créer une release
### Export (faire un backup)
```bash
# Depuis le PC de dev
./scripts/release.sh patch # 1.0.0 → 1.0.1
./scripts/release.sh minor # 1.0.0 → 1.1.0
./scripts/release.sh major # 1.0.0 → 2.0.0
./scripts/release.sh 2.0.0 # Version exacte
pg_dump -U ferme_user -h 127.0.0.1 -d inventory \
--no-owner --no-acl --inserts --column-inserts \
--clean --if-exists > backup_inventory_$(date +%Y%m%d).sql
```
Le script :
1. Vérifie/commit le submodule frontend
2. Met à jour `VERSION` et `api_platform.yaml`
3. Commit et tag les deux repos
4. Affiche les commandes pour push
### Pousser la release
### Import (restaurer un backup)
```bash
# Frontend (submodule)
cd Inventory_frontend && git push && git push --tags && cd ..
# Backend
git push && git push --tags
psql -U ferme_user -h 127.0.0.1 -d inventory -f backup_inventory_YYYYMMDD.sql
```
### Créer la release sur Gitea
1. Aller sur le dépôt Gitea
2. **Releases** > **New Release**
3. Sélectionner le tag `vX.Y.Z`
4. Ajouter les notes de release
---
## Commandes utiles
## Troubleshooting
### Erreur 502 Bad Gateway
PHP-FPM ne tourne pas ou est crashé :
```bash
systemctl status php8.4-fpm
sudo systemctl restart php8.4-fpm
```
### Erreur 403 Forbidden
Problème de permissions sur les fichiers :
```bash
sudo chown -R www-data:www-data /var/www/Inventory/var/
sudo chmod -R 775 /var/www/Inventory/var/
```
### Erreur API "No route found"
Le cache Symfony est probablement périmé :
```bash
php /var/www/Inventory/bin/console cache:clear --env=prod
```
### Frontend ne se met pas à jour
Les fichiers statiques sont en cache. Rebuilder :
```bash
cd /var/www/Inventory/Inventory_frontend
rm -rf .output
npx nuxi generate
```
### L'API retourne 401 sur toutes les requêtes
La session PHP ne se crée pas correctement. Vérifier :
```bash
# Vérifier que le dossier de sessions existe et est accessible
ls -la /var/lib/php/sessions/
# Ou vérifier les logs Symfony
tail -f /var/www/Inventory/var/log/prod.log
```
---
## Commandes utiles en production
```bash
# Logs Nginx
tail -f /var/log/nginx/inventory-error.log
tail -f /var/log/nginx/inventory-access.log
# Logs Symfony
tail -f /var/www/Inventory/var/log/prod.log
@@ -274,55 +301,12 @@ php /var/www/Inventory/bin/console cache:clear --env=prod
# Rebuild frontend
cd /var/www/Inventory/Inventory_frontend && npx nuxi generate
# Status PHP-FPM
# Status des services
systemctl status php8.4-fpm
systemctl status nginx
systemctl status postgresql
# Reload Nginx
# Redémarrer les services
sudo systemctl restart php8.4-fpm
sudo systemctl reload nginx
```
---
## Backup base de données
### Export
```bash
pg_dump -U ferme_user -h 127.0.0.1 -d inventory --no-owner --no-acl --inserts --column-inserts --clean --if-exists > backup_inventory_$(date +%Y%m%d).sql
```
### Import
```bash
psql -U ferme_user -h 127.0.0.1 -d inventory -f backup_inventory_YYYYMMDD.sql
```
---
## Troubleshooting
### Erreur 502 Bad Gateway
```bash
# Vérifier PHP-FPM
systemctl status php8.4-fpm
sudo systemctl restart php8.4-fpm
```
### Erreur 403 Forbidden
```bash
# Vérifier les permissions
sudo chown -R www-data:www-data /var/www/Inventory/var/
sudo chmod -R 775 /var/www/Inventory/var/
```
### Erreur API "No route found"
```bash
# Vider le cache
php /var/www/Inventory/bin/console cache:clear --env=prod
```
### Frontend ne se met pas à jour
```bash
# Rebuild
cd /var/www/Inventory/Inventory_frontend
rm -rf .output
npx nuxi generate
```

269
README.md
View File

@@ -2,52 +2,87 @@
Application de gestion d'inventaire industriel pour **Malio**. Gestion complète du parc machines, des pièces, composants, produits, fournisseurs et documents associés, avec traçabilité et contrôle d'accès par rôles.
## C'est quoi ce projet ?
Inventory est une application web qui permet de gérer un parc de machines industrielles. Concrètement, elle permet de :
- **Cataloguer** les machines d'une usine, site par site
- **Décomposer** chaque machine en composants, pièces et produits (structure arborescente)
- **Suivre** les fournisseurs/constructeurs de chaque élément
- **Stocker** les documents techniques (PDF, images, fiches techniques)
- **Tracer** toutes les modifications (qui a changé quoi, quand) via un journal d'audit
- **Commenter** les fiches pour collaborer entre équipes
- **Gérer les accès** avec un système de rôles (admin, gestionnaire, lecteur)
L'application se compose de deux parties :
- Un **backend** (API REST) qui gère les données, la sécurité et la logique métier
- Un **frontend** (interface web) qui affiche les données et permet l'interaction utilisateur
## Stack technique
| Couche | Technologie | Version |
|--------|-------------|---------|
| Backend | Symfony + API Platform | 8.0 / 4.2 |
| PHP | PHP | >= 8.4 |
| Base de données | PostgreSQL | 16 |
| Frontend | Nuxt (SPA, SSR off) | 4 |
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 |
| CSS | TailwindCSS + DaisyUI | 4 / 5 |
| Conteneurs | Docker Compose | |
| Couche | Technologie | Version | Rôle |
|--------|-------------|---------|------|
| Backend | Symfony + API Platform | 8.0 / 4.2 | API REST, logique métier, sécurité |
| PHP | PHP | >= 8.4 | Langage backend |
| Base de données | PostgreSQL | 16 | Stockage des données |
| Frontend | Nuxt (SPA, SSR off) | 4 | Framework web (rendu côté client) |
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 | Composants d'interface |
| CSS | TailwindCSS + DaisyUI | 4 / 5 | Mise en page et composants visuels |
| Conteneurs | Docker Compose | | Environnement de développement |
## Prérequis
- **Docker** et **Docker Compose**
- **Node.js** >= 20 (via nvm)
- **make**
- **Docker** et **Docker Compose** (pour lancer le projet sans rien installer)
- **Node.js** >= 20 (via [nvm](https://github.com/nvm-sh/nvm))
- **make** (normalement déjà installé sur Linux/macOS)
### Installation de l'environnement
### Guides d'installation de l'environnement
| OS | Documentation |
|----|---------------|
| Windows | [WSL2 + Ubuntu + Docker](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows) |
| Linux | [Docker + nvm](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux) |
## Installation
## Installation rapide
```bash
# 1. Cloner le projet avec le frontend (submodule)
git clone --recurse-submodules <url-du-repo>
cd Inventory
# 2. Démarrer les conteneurs Docker (PHP, PostgreSQL, Adminer)
make start
# 3. Installer les dépendances et builder le projet
make install
```
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `docker/.env.docker.local`.
### Que fait `make install` ?
1. Installe les dépendances PHP (via Composer)
2. Installe les dépendances Node.js (via npm)
3. Build le frontend Nuxt
### Premier lancement
Une fois l'installation terminée, tu peux :
1. Charger des données de test : `make fixtures-load`
2. Lancer le frontend en mode dev : `make dev-nuxt`
3. Ouvrir l'application : http://localhost:3001
## URLs locales
| Service | URL |
|---------|-----|
| API Symfony | http://localhost:8081/api |
| Frontend Nuxt | http://localhost:3001 |
| Adminer (BDD) | http://localhost:5050 |
| PostgreSQL | `localhost:5433` (user: root, pass: root, db: inventory) |
| Service | URL | Description |
|---------|-----|-------------|
| API Symfony | http://localhost:8081/api | Documentation interactive de l'API (Swagger) |
| Frontend Nuxt | http://localhost:3001 | L'application web |
| Adminer (BDD) | http://localhost:5050 | Interface web pour explorer la base de données |
| PostgreSQL | `localhost:5433` | Connexion directe (user: root, pass: root, db: inventory) |
## Commandes
## Commandes utiles
### Docker
@@ -56,26 +91,28 @@ make install
| `make start` | Démarrer les conteneurs |
| `make stop` | Arrêter les conteneurs |
| `make restart` | Redémarrer les conteneurs |
| `make shell` | Shell bash dans le conteneur PHP |
| `make reset` | Reset complet (supprime volumes, réinstalle) |
| `make shell` | Ouvrir un terminal dans le conteneur PHP (pour lancer des commandes Symfony) |
| `make reset` | Reset complet (supprime les volumes, réinstalle tout) |
### Backend
| Commande | Description |
|----------|-------------|
| `make test` | Lancer les tests PHPUnit |
| `make php-cs-fixer-allow-risky` | Formatter le code PHP |
| `make cache-clear` | Vider le cache Symfony |
| `make db-reset` | Reset de la BDD (supprime les données) |
| `make fixtures-load` | Charger les fixtures SQL |
| `make fixtures-dump` | Dumper la BDD dans fixtures/data.sql |
| `make test FILES=tests/Api/Entity/MachineTest.php` | Lancer un test spécifique |
| `make test-setup` | Créer/mettre à jour la base de test |
| `make php-cs-fixer-allow-risky` | Formatter le code PHP (indentation, espaces, etc.) |
| `make cache-clear` | Vider le cache Symfony (à faire si tu as des erreurs bizarres) |
| `make db-reset` | Reset de la BDD (supprime toutes les données) |
| `make fixtures-load` | Charger les données de test |
| `make fixtures-dump` | Sauvegarder la BDD actuelle dans fixtures/data.sql |
### Frontend
| Commande | Description |
|----------|-------------|
| `make dev-nuxt` | Serveur de dev Nuxt |
| `make build-nuxtJS` | Build de production |
| `make dev-nuxt` | Lancer le serveur de dev Nuxt (avec rechargement automatique) |
| `make build-nuxtJS` | Builder le frontend pour la production |
### Release
@@ -85,79 +122,115 @@ make install
Synchronise automatiquement la version dans `VERSION`, `api_platform.yaml` et `nuxt.config.ts`, crée le tag git et pousse les deux repos.
## Architecture
## Architecture globale
### Comment ça marche ?
```
┌──────────────────┐ HTTP (JSON) ┌──────────────────┐ SQL ┌────────────┐
│ Frontend │ ◄─────────────────► │ Backend │ ◄──────────► │ PostgreSQL │
│ (Nuxt/Vue) │ cookies session │ (Symfony/API) │ │ (BDD) │
│ localhost:3001 │ │ localhost:8081 │ │ port 5433 │
└──────────────────┘ └──────────────────┘ └────────────┘
│ │
Interface web API REST + logique
pour l'utilisateur métier + sécurité
```
1. L'utilisateur ouvre le navigateur sur `localhost:3001`
2. Le frontend (Vue/Nuxt) affiche l'interface
3. Quand l'utilisateur fait une action (créer une machine, etc.), le frontend envoie une requête HTTP à l'API backend
4. Le backend valide la requête, vérifie les permissions, exécute la logique métier
5. Le backend lit/écrit dans PostgreSQL et renvoie une réponse JSON
6. Le frontend met à jour l'interface avec les nouvelles données
### Structure du projet
```
Inventory/ # Backend Symfony (repo principal)
├── src/
│ ├── Entity/ # 20 entités Doctrine (attributs PHP 8)
│ ├── Controller/ # 16 contrôleurs custom
│ ├── EventSubscriber/ # 9 subscribers (audit onFlush)
│ ├── EventListener/ # Listeners documents (cleanup, compression)
│ ├── Command/ # 3 commandes CLI
│ ├── Service/ # 3 services (stockage, conversion, PDF)
│ ├── State/ # 3 processeurs API Platform
│ ├── Repository/ # 19 repositories Doctrine
── Security/ # Authenticateur session
│ └── Serializer/ # Normalizer custom (Document)
├── config/ # Configuration Symfony
├── migrations/ # 4 migrations Doctrine (SQL PostgreSQL)
│ ├── Entity/ # Les "modèles" de données (Machine, Piece, etc.)
│ ├── Controller/ # Les endpoints API personnalisés
│ ├── EventSubscriber/ # Logique déclenchée automatiquement (audit, etc.)
│ ├── Command/ # Commandes CLI (lancer via php bin/console)
│ ├── Service/ # Services métier (stockage fichiers, PDF, etc.)
│ ├── State/ # Processeurs API Platform (hashage mot de passe, upload)
│ ├── Repository/ # Requêtes BDD personnalisées
│ ├── Security/ # Authentification par session
── Serializer/ # Conversion entité ↔ JSON personnalisée
├── config/ # Configuration Symfony (routes, sécurité, etc.)
├── migrations/ # Scripts de modification de la BDD
├── fixtures/ # Données de test (SQL)
├── tests/ # Tests automatisés (PHPUnit)
├── scripts/ # Utilitaires (release, migration, normalisation)
├── docker/ # Dockerfile + config Docker
├── makefile # Commandes de dev
├── VERSION # Version courante (semver)
└── Inventory_frontend/ # Submodule git (repo séparé)
├── app/pages/ # 36 pages Nuxt (file-based routing)
├── app/components/ # 57 composants Vue
├── app/composables/ # 45 composables
── app/shared/ # Types, utils, validation
├── makefile # Commandes de dev raccourcies
├── VERSION # Version courante (ex: 1.8.1)
└── Inventory_frontend/ # Submodule git (frontend, repo séparé)
├── app/pages/ # Les pages de l'app (1 fichier = 1 route URL)
├── app/components/ # Composants Vue réutilisables
├── app/composables/ # Logique métier partagée (appels API, états)
── app/shared/ # Types TypeScript, utilitaires, validation
├── app/middleware/ # Vérification de session automatique
└── app/services/ # Couche service (wrappers API)
```
### Entités principales
### Entités principales (les "tables" de la BDD)
| Entité | Description |
|--------|-------------|
| `Machine` | Machines du parc industriel |
| `Composant` | Composants rattachés aux machines |
| `Piece` | Pièces détachées |
| `Product` | Produits (consommables, outillage) |
| `Site` | Sites physiques / usines |
| `Constructeur` | Fournisseurs / fabricants |
| `TypeMachine` | Types de machines avec squelettes de structure |
| `ModelType` | Catégories (pièce, composant, produit) avec champs personnalisés |
| `CustomField` / `CustomFieldValue` | Champs personnalisés extensibles |
| `Document` | Documents uploadés (stockage fichier + compression PDF) |
| `AuditLog` | Journal d'audit (diff + snapshot) |
| `Comment` | Commentaires / tickets sur les fiches |
| `Profile` | Utilisateurs avec rôles |
| Entité | Description | Exemple |
|--------|-------------|---------|
| `Machine` | Machines du parc industriel | "CNC Mazak 01" |
| `Composant` | Composants fonctionnels d'une machine | "Broche principale" |
| `Piece` | Pièces détachées/de rechange | "Roulement SKF 6205" |
| `Product` | Produits fournisseur (consommables, outillage) | "Huile de coupe X" |
| `Site` | Sites physiques / usines | "Usine de Strasbourg" |
| `Constructeur` | Fournisseurs / fabricants | "SKF", "Mazak" |
| `ModelType` | Catégories avec squelettes de structure | "Type: Moteur électrique" |
| `CustomField` / `CustomFieldValue` | Champs personnalisés (dynamiques) | "Tension : 220V" |
| `Document` | Documents uploadés (PDF, images, etc.) | "Fiche technique CNC.pdf" |
| `AuditLog` | Journal d'audit (historique des modifications) | "Machine X modifiée par Jean" |
| `Comment` | Commentaires / tickets sur les fiches | "Vérifier le roulement" |
| `Profile` | Comptes utilisateurs avec rôles | "admin@malio.fr (ADMIN)" |
### Commandes Symfony
### Structure hiérarchique d'une machine
| Commande | Description |
|----------|-------------|
| `app:compress-pdf` | Compresser les PDFs existants (supporte `--dry-run`) |
| `app:migrate-documents-to-filesystem` | Migrer les documents Base64 vers le système de fichiers |
| `app:init-profile-passwords` | Initialiser mots de passe et rôles en masse |
Une machine peut contenir une arborescence de composants, pièces et produits :
```
Machine "CNC Mazak 01"
├── Composant "Broche principale"
│ ├── Pièce "Roulement avant"
│ │ └── Produit "Graisse SKF LGMT2"
│ └── Pièce "Joint d'étanchéité"
├── Composant "Système hydraulique"
│ ├── Pièce "Pompe HP"
│ └── Produit "Huile hydraulique ISO 46"
└── Produit "Filtre à air cabine"
```
### Rôles et permissions
```
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
ROLE_ADMIN → Tout faire + gérer les utilisateurs
↓ hérite de
ROLE_GESTIONNAIRE → Créer, modifier, supprimer les données
↓ hérite de
ROLE_VIEWER → Lecture seule sur toutes les données
↓ hérite de
ROLE_USER → Accès de base (rôle minimum)
```
- **ADMIN** : accès complet, gestion des profils
- **GESTIONNAIRE** : CRUD sur toutes les entités, résolution des commentaires
- **VIEWER** : lecture seule sur toutes les entités
- **USER** : accès de base
### Authentification
Authentification par **session (cookies)**, pas de JWT. Le profil actif est stocké en session côté serveur.
Authentification par **session (cookies)**, pas de JWT. Le profil actif est stocké en session côté serveur. Concrètement :
### Base de données
1. L'utilisateur choisit son profil sur la page de login
2. Il entre son mot de passe
3. Le backend crée une session et envoie un cookie au navigateur
4. À chaque requête suivante, le navigateur envoie automatiquement ce cookie
5. Le backend vérifie le cookie et identifie l'utilisateur
### Base de données — Points importants
PostgreSQL 16 avec les particularités suivantes :
- **IDs** : chaînes CUID (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
@@ -171,7 +244,7 @@ PostgreSQL 16 avec les particularités suivantes :
|---------|-------|------|------|
| `web` | PHP 8.4 + Apache + Node | 8081, 3001 | API Symfony + Nuxt dev |
| `db` | PostgreSQL 16 Alpine | 5433 | Base de données |
| `adminer` | Adminer | 5050 | Interface web BDD |
| `adminer` | Adminer | 5050 | Interface web pour explorer la BDD |
## Xdebug
@@ -191,30 +264,42 @@ Configuration PhpStorm / VSCode :
- `develop` : branche principale de dev (cible des PR)
- `feat/xxx`, `fix/xxx`, `refactor/xxx` : branches de travail
### Convention de commit
### Convention de commit (enforced par un hook)
```
<type>(<scope>) : <message>
```
**Espace obligatoire autour du `:`**. Types : `feat`, `fix`, `perf`, `refactor`, `chore`, `docs`, `test`, `style`, `build`, `ci`, `revert`, `wip`.
**Espace obligatoire autour du `:`**. Types autorisés (minuscules) :
`feat`, `fix`, `perf`, `refactor`, `chore`, `docs`, `test`, `style`, `build`, `ci`, `revert`, `wip`
Exemples :
```
feat(machines) : add clone functionality
fix(documents) : prevent duplicate upload
refactor(audit) : merge history controllers
chore(deps) : update composer packages
```
### Pre-commit hook
1. php-cs-fixer sur les fichiers PHP stagés
2. PHPUnit — bloque le commit si les tests échouent
Le hook `pre-commit` s'exécute automatiquement avant chaque commit :
1. **php-cs-fixer** — Formate automatiquement les fichiers PHP modifiés
2. **PHPUnit** — Lance les tests. Si un test échoue, le commit est bloqué
### Submodule frontend
Le frontend est un **submodule git** dans `Inventory_frontend/`. Workflow :
Le frontend est un **submodule git** dans `Inventory_frontend/` (c'est un repo git séparé, inclus dans le repo principal). Workflow de commit :
1. Commiter dans `Inventory_frontend/` d'abord
2. Commiter dans le repo principal pour mettre à jour le pointeur
2. Commiter dans le repo principal pour mettre à jour le pointeur du submodule
3. Pousser les deux repos
## Documentation complémentaire
## Documentation détaillée
- [DEPLOY.md](DEPLOY.md) : guide de déploiement serveur (Nginx, PHP-FPM, PostgreSQL)
- [RELEASE.md](RELEASE.md) : processus de release et versioning
- [CHANGELOG.md](CHANGELOG.md) : historique des versions
- [Frontend README](Inventory_frontend/README.md) : documentation du frontend Nuxt
- **[docs/BACKEND.md](docs/BACKEND.md)** : guide complet du backend (entités, controllers, API, audit, tests)
- **[docs/FRONTEND.md](docs/FRONTEND.md)** : guide complet du frontend (pages, composables, composants, patterns)
- **[DEPLOY.md](DEPLOY.md)** : guide de déploiement serveur (Nginx, PHP-FPM, PostgreSQL)
- **[RELEASE.md](RELEASE.md)** : processus de release et versioning
- **[CHANGELOG.md](CHANGELOG.md)** : historique des versions
- **[Frontend README](Inventory_frontend/README.md)** : documentation du frontend Nuxt

View File

@@ -1,12 +1,18 @@
# Guide de Release
## Versioning
## C'est quoi une release ?
Le projet utilise le [Semantic Versioning](https://semver.org/) (SemVer) : `MAJOR.MINOR.PATCH`
Une release c'est une **version officielle** de l'application. Chaque release a un numéro de version (ex: `1.8.1`) et est marquée par un **tag git**.
- **MAJOR** : Changements incompatibles avec les versions précédentes
- **MINOR** : Nouvelles fonctionnalités rétrocompatibles
- **PATCH** : Corrections de bugs rétrocompatibles
## Versioning (Semantic Versioning)
Le projet utilise le [Semantic Versioning](https://semver.org/) : `MAJOR.MINOR.PATCH`
| Type | Quand l'utiliser | Exemple |
|------|------------------|---------|
| **PATCH** | Correction de bug, pas de nouvelle fonctionnalité | `1.8.0``1.8.1` |
| **MINOR** | Nouvelle fonctionnalité, rétrocompatible | `1.8.1``1.9.0` |
| **MAJOR** | Changement majeur, potentiellement incompatible | `1.9.0``2.0.0` |
La version est centralisée dans le fichier `VERSION` à la racine du projet.
@@ -14,9 +20,9 @@ La version est centralisée dans le fichier `VERSION` à la racine du projet.
### Prérequis
- Tous les changements doivent être commités
- Les tests doivent passer
- Être sur la branche à releaser (ex: `main`, `develop`)
- Tous les changements doivent être commités (pas de fichiers modifiés non commités)
- Les tests doivent passer (`make test`)
- Être sur la branche à releaser (généralement `develop` ou `master`)
### Utilisation du script
@@ -24,28 +30,38 @@ La version est centralisée dans le fichier `VERSION` à la racine du projet.
# Afficher l'aide et la version actuelle
./scripts/release.sh
# Bump patch : 1.0.0 → 1.0.1
# Bump patch : 1.8.1 → 1.8.2
./scripts/release.sh patch
# Bump minor : 1.0.0 → 1.1.0
# Bump minor : 1.8.1 → 1.9.0
./scripts/release.sh minor
# Bump major : 1.0.0 → 2.0.0
# Bump major : 1.8.1 → 2.0.0
./scripts/release.sh major
# Version spécifique
./scripts/release.sh 2.0.0
```
Le script :
1. Met à jour le fichier `VERSION`
2. Met à jour `config/packages/api_platform.yaml`
3. Crée un commit `chore(release): vX.Y.Z`
4. Crée le tag `vX.Y.Z`
### Que fait le script ?
1. Vérifie qu'il n'y a pas de changements non commités
2. Vérifie/commit le submodule frontend si nécessaire
3. Met à jour le fichier `VERSION` avec le nouveau numéro
4. Met à jour `config/packages/api_platform.yaml` (version affichée dans l'API)
5. Crée un commit `chore(release) : vX.Y.Z`
6. Crée le tag git `vX.Y.Z`
7. Affiche les commandes pour pousser
### Pousser la release
Après avoir exécuté le script :
```bash
# Pousser le frontend d'abord (si modifié)
cd Inventory_frontend && git push && git push --tags && cd ..
# Pousser le backend
git push && git push --tags
```
@@ -54,12 +70,21 @@ git push && git push --tags
1. Aller sur le dépôt Gitea
2. **Releases** > **New Release**
3. Sélectionner le tag `vX.Y.Z`
4. Titre : `v1.0.0` (ou avec un nom descriptif)
5. Description : résumé des changements (voir section Notes de release)
4. Titre : `vX.Y.Z` (ou avec un nom descriptif)
5. Description : résumé des changements (copier depuis CHANGELOG.md)
## Fichiers impactés par le versioning
| Fichier | Rôle |
|---------|------|
| `VERSION` | Source unique de vérité |
| `config/packages/api_platform.yaml` | Version affichée dans la doc API (Swagger) |
| `Inventory_frontend/nuxt.config.ts` | Lit `VERSION` au build pour l'afficher dans le footer |
| Footer de l'app | Affiche `v{{ appVersion }}` |
## Notes de release
Template pour les notes de release :
Template pour les notes de release (à copier dans Gitea) :
```markdown
## Nouveautés
@@ -73,66 +98,25 @@ Template pour les notes de release :
## Changements
- Refactoring de Z
- Mise à jour des dépendances
## Migration requise
\`\`\`bash
docker compose exec web php bin/console doctrine:migrations:migrate
\`\`\`
```
## Fichiers impactés par le versioning
## Déploiement après une release
| Fichier | Usage |
|---------|-------|
| `VERSION` | Source unique de vérité |
| `config/packages/api_platform.yaml` | Version affichée dans l'API |
| `Inventory_frontend/nuxt.config.ts` | Lit VERSION au build |
| Footer de l'app | Affiche `v{{ appVersion }}` |
Voir [DEPLOY.md](DEPLOY.md) pour les instructions de mise à jour en production.
## Déploiement en production
### 1. Base de données
Dump de la base locale :
```bash
pg_dump -h localhost -p 5433 -U root -d inventory > backup_v1.0.0.sql
```
Import en production :
```bash
psql -h <PROD_HOST> -U <PROD_USER> -d inventory < backup_v1.0.0.sql
```
### 2. Variables d'environnement production
Créer un fichier `.env.local` en production avec :
```env
APP_ENV=prod
APP_SECRET=<générer avec: openssl rand -hex 32>
DATABASE_URL="postgresql://user:password@host:5432/inventory?serverVersion=16"
CORS_ALLOW_ORIGIN='^https://votre-domaine\.com$'
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=<votre-passphrase>
```
### 3. Build production
Backend :
En résumé :
```bash
# Sur le serveur de production
cd /var/www/Inventory
git pull
git submodule update --init --recursive
composer install --no-dev --optimize-autoloader
php bin/console cache:clear --env=prod
php bin/console doctrine:migrations:migrate --no-interaction
php bin/console cache:clear --env=prod
cd Inventory_frontend && npm install && npx nuxi generate
```
Frontend :
```bash
cd Inventory_frontend
NUXT_PUBLIC_API_BASE_URL=https://api.votre-domaine.com yarn build
```
### 4. Checklist avant mise en prod
- [ ] Tests passent
- [ ] Migrations DB testées
- [ ] Variables d'environnement configurées
- [ ] Clés JWT générées
- [ ] CORS configuré
- [ ] SSL/HTTPS actif
- [ ] Backup de la DB prod existante (si upgrade)

View File

@@ -1 +1 @@
1.8.1
1.9.0

View File

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

257
composer.lock generated
View File

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

View File

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

View File

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

View File

@@ -1,4 +1,9 @@
security:
# Login controller already calls $session->migrate(true) on login.
# Keeping 'migrate' would regenerate the session ID on every authenticated
# API request, which breaks concurrent requests from the SPA (race condition).
session_fixation_strategy: none
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
password_hashers:
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
@@ -18,24 +23,13 @@ security:
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
login:
pattern: ^/api/login_check
stateless: true
provider: app_user_provider
json_login:
check_path: /api/login_check
username_path: email
password_path: password
success_handler: lexik_jwt_authentication.handler.authentication_success
failure_handler: lexik_jwt_authentication.handler.authentication_failure
session_public:
pattern: ^/api/session/profiles?$
security: false
api:
pattern: ^/api
stateless: true
stateless: false
custom_authenticators:
- App\Security\SessionProfileAuthenticator
@@ -54,7 +48,7 @@ security:
- { path: ^/api/session/profiles, roles: PUBLIC_ACCESS, methods: [GET] }
- { path: ^/api/admin, roles: ROLE_ADMIN }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/test, roles: PUBLIC_ACCESS }
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
- { path: ^/docs, roles: PUBLIC_ACCESS }
- { path: ^/contexts, roles: PUBLIC_ACCESS }
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }

View File

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

View File

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

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

1052
docs/FRONTEND.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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

File diff suppressed because it is too large Load Diff

View 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)

View 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).

View File

@@ -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');
--

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -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(),
]);
}
}

View 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(),
]);
}
}

View 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(),
]);
}
}

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ use App\Entity\MachineProductLink;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Entity\Product;
use App\Entity\Site;
use App\Repository\ComposantRepository;
use App\Repository\MachineComponentLinkRepository;
use App\Repository\MachinePieceLinkRepository;
@@ -123,7 +124,7 @@ class MachineStructureController extends AbstractController
return $this->json(['success' => false, 'error' => 'name et siteId sont requis.'], 400);
}
$site = $this->entityManager->getRepository(\App\Entity\Site::class)->find($payload['siteId']);
$site = $this->entityManager->getRepository(Site::class)->find($payload['siteId']);
if (!$site) {
return $this->json(['success' => false, 'error' => 'Site introuvable.'], 404);
}
@@ -241,6 +242,7 @@ class MachineStructureController extends AbstractController
$newLink->setNameOverride($link->getNameOverride());
$newLink->setReferenceOverride($link->getReferenceOverride());
$newLink->setPrixOverride($link->getPrixOverride());
$newLink->setQuantity($link->getQuantity());
$parent = $link->getParentLink();
if ($parent && isset($componentLinkMap[$parent->getId()])) {
@@ -394,6 +396,11 @@ class MachineStructureController extends AbstractController
$this->applyOverrides($link, $entry['overrides'] ?? null);
if (!isset($entry['parentComponentLinkId']) && !isset($entry['parentLinkId'])) {
$quantity = isset($entry['quantity']) ? (int) $entry['quantity'] : $link->getQuantity();
$link->setQuantity(max(1, $quantity));
}
$pendingParents[$linkId] = $this->resolveIdentifier($entry, [
'parentComponentLinkId',
'parentLinkId',
@@ -572,7 +579,7 @@ class MachineStructureController extends AbstractController
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
'customFields' => $this->normalizeCustomFields($machine->getCustomFields()),
'documents' => null,
'customFieldValues' => null,
'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()),
];
}
@@ -635,10 +642,31 @@ class MachineStructureController extends AbstractController
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'quantity' => $this->resolvePieceQuantity($link),
];
}, $links);
}
private function resolvePieceQuantity(MachinePieceLink $link): int
{
$parentLink = $link->getParentLink();
if (!$parentLink) {
return $link->getQuantity();
}
$composant = $parentLink->getComposant();
$piece = $link->getPiece();
foreach ($composant->getPieceSlots() as $slot) {
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
return $slot->getQuantity();
}
}
return $link->getQuantity();
}
private function normalizeProductLinks(array $links): array
{
return array_map(function (MachineProductLink $link): array {
@@ -670,6 +698,7 @@ class MachineStructureController extends AbstractController
'typeComposant' => $this->normalizeModelType($type),
'productId' => $composant->getProduct()?->getId(),
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
'structure' => $this->buildStructureFromSlots($composant),
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
'documents' => [],
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [],
@@ -677,6 +706,48 @@ class MachineStructureController extends AbstractController
];
}
private function buildStructureFromSlots(Composant $composant): array
{
$pieces = [];
foreach ($composant->getPieceSlots() as $slot) {
$pieceData = [
'slotId' => $slot->getId(),
'typePieceId' => $slot->getTypePiece()?->getId(),
'quantity' => $slot->getQuantity(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
];
if ($slot->getSelectedPiece()) {
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece());
}
$pieces[] = $pieceData;
}
$subcomponents = [];
foreach ($composant->getSubcomponentSlots() as $slot) {
$subcomponents[] = [
'alias' => $slot->getAlias(),
'familyCode' => $slot->getFamilyCode(),
'typeComposantId' => $slot->getTypeComposant()?->getId(),
'selectedComponentId' => $slot->getSelectedComposant()?->getId(),
];
}
$products = [];
foreach ($composant->getProductSlots() as $slot) {
$products[] = [
'typeProductId' => $slot->getTypeProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
];
}
return [
'pieces' => $pieces,
'subcomponents' => $subcomponents,
'products' => $products,
];
}
private function normalizePiece(Piece $piece): array
{
$type = $piece->getTypePiece();
@@ -699,16 +770,19 @@ class MachineStructureController extends AbstractController
private function normalizeProduct(Product $product): array
{
$type = $product->getTypeProduct();
return [
'id' => $product->getId(),
'name' => $product->getName(),
'reference' => $product->getReference(),
'supplierPrice' => $product->getSupplierPrice(),
'typeProductId' => $product->getTypeProduct()?->getId(),
'typeProduct' => $this->normalizeModelType($product->getTypeProduct()),
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
'documents' => [],
'customFields' => [],
'id' => $product->getId(),
'name' => $product->getName(),
'reference' => $product->getReference(),
'supplierPrice' => $product->getSupplierPrice(),
'typeProductId' => $type?->getId(),
'typeProduct' => $this->normalizeModelType($type),
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
'documents' => [],
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getProductCustomFields()) : [],
'customFieldValues' => $this->normalizeCustomFieldValues($product->getCustomFieldValues()),
];
}
@@ -772,7 +846,7 @@ class MachineStructureController extends AbstractController
if (!$cfv instanceof CustomFieldValue) {
continue;
}
$cf = $cfv->getCustomField();
$cf = $cfv->getCustomField();
$items[] = [
'id' => $cfv->getId(),
'value' => $cfv->getValue(),

View 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);
}
}

View File

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

View File

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

View File

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

View File

@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
class TestController extends AbstractController
{
#[Route('/api/test', name: 'api_test', methods: ['GET', 'POST'])]
public function test(): JsonResponse
{
return $this->json(['status' => 'ok', 'message' => 'Test endpoint works!']);
}
}

View File

@@ -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,
) {}
}

View 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,
];
}
}

View 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,
];
}
}

View File

@@ -12,6 +12,7 @@ use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Entity\Trait\CuidEntityTrait;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
@@ -23,6 +24,7 @@ use Doctrine\ORM\Mapping as ORM;
#[ApiFilter(SearchFilter::class, properties: ['entityType' => 'exact', 'entityId' => 'exact', 'status' => 'exact', 'entityName' => 'ipartial'])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'authorName', 'status'])]
#[ApiResource(
description: 'Commentaires et annotations. Permet aux utilisateurs de commenter les machines, pièces, composants, produits et catégories. Les commentaires peuvent être marqués comme résolus.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
@@ -35,6 +37,8 @@ use Doctrine\ORM\Mapping as ORM;
)]
class Comment
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
@@ -75,29 +79,12 @@ class Comment
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')]
private DateTimeImmutable $updatedAt;
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function getContent(): string
{
return $this->content;
@@ -217,19 +204,4 @@ class Comment
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -14,6 +14,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ComposantRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -28,6 +29,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact', 'typeComposant.name' => 'ipartial'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
#[ApiResource(
description: 'Composants du catalogue. Un composant représente un élément fonctionnel rattaché à une machine, avec un type, des fournisseurs et des documents.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
@@ -42,6 +44,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class Composant
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'document:list'])]
@@ -63,10 +67,6 @@ class Composant
#[Groups(['composant:read'])]
private ?string $prix = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['composant:read'])]
private ?array $structure = null;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'composants')]
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true)]
#[Groups(['composant:read'])]
@@ -109,6 +109,31 @@ class Composant
#[ORM\OneToMany(mappedBy: 'composant', targetEntity: MachineComponentLink::class)]
private Collection $machineLinks;
/**
* @var Collection<int, ComposantPieceSlot>
*/
#[ORM\OneToMany(targetEntity: ComposantPieceSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $pieceSlots;
/**
* @var Collection<int, ComposantSubcomponentSlot>
*/
#[ORM\OneToMany(targetEntity: ComposantSubcomponentSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $subcomponentSlots;
/**
* @var Collection<int, ComposantProductSlot>
*/
#[ORM\OneToMany(targetEntity: ComposantProductSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $productSlots;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
#[Groups(['composant:read'])]
private int $version = 1;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['composant:read'])]
private DateTimeImmutable $createdAt;
@@ -119,40 +144,15 @@ class Composant
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->constructeurs = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->customFieldValues = new ArrayCollection();
$this->machineLinks = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
$this->pieceSlots = new ArrayCollection();
$this->subcomponentSlots = new ArrayCollection();
$this->productSlots = new ArrayCollection();
}
public function getName(): string
@@ -203,18 +203,6 @@ class Composant
return $this;
}
public function getStructure(): ?array
{
return $this->structure;
}
public function setStructure(?array $structure): static
{
$this->structure = $structure;
return $this;
}
public function getTypeComposant(): ?ModelType
{
return $this->typeComposant;
@@ -295,18 +283,143 @@ class Composant
return $this->customFieldValues;
}
public function getCreatedAt(): DateTimeImmutable
/**
* @return Collection<int, ComposantPieceSlot>
*/
public function getPieceSlots(): Collection
{
return $this->createdAt;
return $this->pieceSlots;
}
public function getUpdatedAt(): DateTimeImmutable
public function addPieceSlot(ComposantPieceSlot $pieceSlot): static
{
return $this->updatedAt;
if (!$this->pieceSlots->contains($pieceSlot)) {
$this->pieceSlots->add($pieceSlot);
$pieceSlot->setComposant($this);
}
return $this;
}
private function generateCuid(): string
public function removePieceSlot(ComposantPieceSlot $pieceSlot): static
{
return 'cl'.bin2hex(random_bytes(12));
$this->pieceSlots->removeElement($pieceSlot);
return $this;
}
/**
* @return Collection<int, ComposantSubcomponentSlot>
*/
public function getSubcomponentSlots(): Collection
{
return $this->subcomponentSlots;
}
public function addSubcomponentSlot(ComposantSubcomponentSlot $subcomponentSlot): static
{
if (!$this->subcomponentSlots->contains($subcomponentSlot)) {
$this->subcomponentSlots->add($subcomponentSlot);
$subcomponentSlot->setComposant($this);
}
return $this;
}
public function removeSubcomponentSlot(ComposantSubcomponentSlot $subcomponentSlot): static
{
$this->subcomponentSlots->removeElement($subcomponentSlot);
return $this;
}
/**
* @return Collection<int, ComposantProductSlot>
*/
public function getProductSlots(): Collection
{
return $this->productSlots;
}
/**
* Virtual property — rebuilds the legacy structure JSON from slot tables.
*
* @return null|array{pieces: list<array<string, mixed>>, products: list<array<string, mixed>>, subcomponents: list<array<string, mixed>>}
*/
#[Groups(['composant:read'])]
public function getStructure(): ?array
{
$pieces = [];
foreach ($this->pieceSlots as $slot) {
$pieces[] = [
'slotId' => $slot->getId(),
'typePieceId' => $slot->getTypePiece()?->getId(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
'quantity' => $slot->getQuantity(),
'position' => $slot->getPosition(),
];
}
$products = [];
foreach ($this->productSlots as $slot) {
$products[] = [
'slotId' => $slot->getId(),
'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(),
'position' => $slot->getPosition(),
];
}
$subcomponents = [];
foreach ($this->subcomponentSlots as $slot) {
$subcomponents[] = [
'slotId' => $slot->getId(),
'alias' => $slot->getAlias(),
'familyCode' => $slot->getFamilyCode(),
'typeComposantId' => $slot->getTypeComposant()?->getId(),
'selectedComponentId' => $slot->getSelectedComposant()?->getId(),
'position' => $slot->getPosition(),
];
}
if (empty($pieces) && empty($products) && empty($subcomponents)) {
return null;
}
return [
'pieces' => $pieces,
'products' => $products,
'subcomponents' => $subcomponents,
];
}
public function addProductSlot(ComposantProductSlot $productSlot): static
{
if (!$this->productSlots->contains($productSlot)) {
$this->productSlots->add($productSlot);
$productSlot->setComposant($this);
}
return $this;
}
public function removeProductSlot(ComposantProductSlot $productSlot): static
{
$this->productSlots->removeElement($productSlot);
return $this;
}
public function getVersion(): int
{
return $this->version;
}
public function incrementVersion(): static
{
++$this->version;
return $this;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ConstructeurRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -18,12 +19,14 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')]
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
#[ORM\Table(name: 'constructeurs')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
@@ -37,12 +40,15 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
)]
class Constructeur
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
private string $name;
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
private ?string $name = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $email = null;
@@ -82,43 +88,15 @@ class Constructeur
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->machines = new ArrayCollection();
$this->composants = new ArrayCollection();
$this->pieces = new ArrayCollection();
$this->products = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
public function getName(): ?string
{
return $this->name;
}
@@ -153,19 +131,4 @@ class Constructeur
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\CustomFieldRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -23,6 +24,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Table(name: 'custom_fields')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Définitions de champs personnalisés. Permet de créer des champs dynamiques (texte, nombre, date, etc.) applicables aux machines, pièces, composants et produits.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
@@ -34,6 +36,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class CustomField
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
@@ -92,39 +96,11 @@ class CustomField
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->customFieldValues = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
@@ -209,18 +185,39 @@ class CustomField
return $this;
}
public function getCreatedAt(): DateTimeImmutable
public function getTypeComposant(): ?ModelType
{
return $this->createdAt;
return $this->typeComposant;
}
public function getUpdatedAt(): DateTimeImmutable
public function setTypeComposant(?ModelType $typeComposant): static
{
return $this->updatedAt;
$this->typeComposant = $typeComposant;
return $this;
}
private function generateCuid(): string
public function getTypePiece(): ?ModelType
{
return 'cl'.bin2hex(random_bytes(12));
return $this->typePiece;
}
public function setTypePiece(?ModelType $typePiece): static
{
$this->typePiece = $typePiece;
return $this;
}
public function getTypeProduct(): ?ModelType
{
return $this->typeProduct;
}
public function setTypeProduct(?ModelType $typeProduct): static
{
$this->typeProduct = $typeProduct;
return $this;
}
}

View File

@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\CustomFieldValueRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
@@ -21,6 +22,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Table(name: 'custom_field_values')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Valeurs des champs personnalisés. Stocke la valeur concrète d\'un champ personnalisé pour une entité donnée (machine, pièce, composant ou produit).',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
@@ -32,6 +34,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class CustomFieldValue
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
@@ -70,36 +74,12 @@ class CustomFieldValue
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private DateTimeImmutable $updatedAt;
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getValue(): string
{
return $this->value;
@@ -171,19 +151,4 @@ class CustomFieldValue
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -14,6 +14,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\DocumentRepository;
use App\State\DocumentUploadProcessor;
use DateTimeImmutable;
@@ -28,6 +29,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product'])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])]
#[ApiResource(
description: 'Documents et fichiers. Gestion des fichiers joints (PDF, images, etc.) rattachés aux machines, pièces, composants, produits ou sites. Upload via multipart/form-data.',
operations: [
new GetCollection(
security: "is_granted('ROLE_VIEWER')",
@@ -52,6 +54,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class Document
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
@@ -66,7 +70,6 @@ class Document
private string $filename;
#[ORM\Column(type: Types::TEXT)]
#[Groups(['document:detail', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $path;
#[ORM\Column(type: Types::STRING, length: 100, name: 'mimeType')]
@@ -109,36 +112,12 @@ class Document
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt;
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
@@ -258,19 +237,4 @@ class Document
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\MachineRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -18,11 +19,13 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: MachineRepository::class)]
#[ORM\Table(name: 'machines')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
@@ -34,6 +37,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class Machine
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list'])]
@@ -51,7 +55,8 @@ class Machine
#[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'machines')]
#[ORM\JoinColumn(name: 'siteId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Site $site;
#[Assert\NotNull(message: 'Le site est obligatoire.')]
private ?Site $site = null;
/**
* @var Collection<int, Constructeur>
@@ -108,6 +113,9 @@ class Machine
public function __construct()
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
$this->constructeurs = new ArrayCollection();
$this->componentLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection();
@@ -117,36 +125,6 @@ class Machine
$this->customFieldValues = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
@@ -183,12 +161,12 @@ class Machine
return $this;
}
public function getSite(): Site
public function getSite(): ?Site
{
return $this->site;
}
public function setSite(Site $site): static
public function setSite(?Site $site): static
{
$this->site = $site;
@@ -232,6 +210,22 @@ class Machine
return $this->constructeurs;
}
public function addConstructeur(Constructeur $constructeur): static
{
if (!$this->constructeurs->contains($constructeur)) {
$this->constructeurs->add($constructeur);
}
return $this;
}
public function removeConstructeur(Constructeur $constructeur): static
{
$this->constructeurs->removeElement($constructeur);
return $this;
}
/**
* @return Collection<int, MachineComponentLink>
*/
@@ -271,19 +265,4 @@ class Machine
{
return $this->customFieldValues;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\MachineComponentLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -22,6 +23,7 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Table(name: 'machine_component_links')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Liaisons machinecomposant. Représente le rattachement d\'un composant à une machine, avec quantité, position dans la hiérarchie et surcharges éventuelles (nom, référence).',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
@@ -33,6 +35,8 @@ use Doctrine\ORM\Mapping as ORM;
)]
class MachineComponentLink
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
@@ -84,41 +88,13 @@ class MachineComponentLink
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->childLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getMachine(): Machine
{
return $this->machine;
@@ -190,9 +166,4 @@ class MachineComponentLink
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -11,17 +11,20 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\MachinePieceLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: MachinePieceLinkRepository::class)]
#[ORM\Table(name: 'machine_piece_links')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Liaisons machinepièce. Représente le rattachement d\'une pièce à une machine, avec quantité, position dans la hiérarchie et surcharges éventuelles (nom, référence, prix).',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
@@ -33,6 +36,8 @@ use Doctrine\ORM\Mapping as ORM;
)]
class MachinePieceLink
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
@@ -64,6 +69,10 @@ class MachinePieceLink
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true, name: 'prixOverride')]
private ?string $prixOverride = null;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
#[Assert\GreaterThanOrEqual(1)]
private int $quantity = 1;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
@@ -72,39 +81,11 @@ class MachinePieceLink
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->productLinks = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getMachine(): Machine
{
return $this->machine;
@@ -177,8 +158,15 @@ class MachinePieceLink
return $this;
}
private function generateCuid(): string
public function getQuantity(): int
{
return 'cl'.bin2hex(random_bytes(12));
return $this->quantity;
}
public function setQuantity(int $quantity): static
{
$this->quantity = $quantity;
return $this;
}
}

View File

@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\MachineProductLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -22,6 +23,7 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Table(name: 'machine_product_links')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Liaisons machineproduit. Représente le rattachement d\'un produit à une machine, avec quantité, position dans la hiérarchie et surcharges éventuelles (nom, référence, prix).',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
@@ -33,6 +35,8 @@ use Doctrine\ORM\Mapping as ORM;
)]
class MachineProductLink
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
@@ -71,39 +75,11 @@ class MachineProductLink
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->childLinks = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getMachine(): Machine
{
return $this->machine;
@@ -163,9 +139,4 @@ class MachineProductLink
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -14,8 +14,10 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Enum\ModelCategory;
use App\Repository\ModelTypeRepository;
use App\State\ModelTypeProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -30,12 +32,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiFilter(SearchFilter::class, properties: ['category' => 'exact', 'name' => 'ipartial'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
#[ApiResource(
description: 'Types et catégories. Référentiel de classification pour les machines, pièces, composants et produits. Chaque type appartient à une catégorie (machine, piece, composant, product) et peut être converti.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ModelTypeProcessor::class),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ModelTypeProcessor::class),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ModelTypeProcessor::class),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
paginationClientItemsPerPage: true,
@@ -43,6 +46,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class ModelType
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['type_machine:read', 'model_type:read', 'product:read', 'composant:read', 'piece:read'])]
@@ -68,18 +73,6 @@ class ModelType
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])]
private ?string $description = null;
#[ORM\Column(type: Types::JSON, nullable: true, name: 'componentSkeleton')]
#[Groups(['model_type:read', 'composant:read'])]
private ?array $componentSkeleton = null;
#[ORM\Column(type: Types::JSON, nullable: true, name: 'pieceSkeleton')]
#[Groups(['model_type:read', 'piece:read'])]
private ?array $pieceSkeleton = null;
#[ORM\Column(type: Types::JSON, nullable: true, name: 'productSkeleton')]
#[Groups(['model_type:read', 'product:read'])]
private ?array $productSkeleton = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['model_type:read'])]
private DateTimeImmutable $createdAt;
@@ -126,44 +119,40 @@ class ModelType
#[ORM\OneToMany(mappedBy: 'typeProduct', targetEntity: CustomField::class)]
private Collection $productCustomFields;
/**
* @var Collection<int, SkeletonPieceRequirement>
*/
#[ORM\OneToMany(targetEntity: SkeletonPieceRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $skeletonPieceRequirements;
/**
* @var Collection<int, SkeletonProductRequirement>
*/
#[ORM\OneToMany(targetEntity: SkeletonProductRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $skeletonProductRequirements;
/**
* @var Collection<int, SkeletonSubcomponentRequirement>
*/
#[ORM\OneToMany(targetEntity: SkeletonSubcomponentRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $skeletonSubcomponentRequirements;
public function __construct()
{
$this->composants = new ArrayCollection();
$this->pieces = new ArrayCollection();
$this->products = new ArrayCollection();
$this->customFields = new ArrayCollection();
$this->pieceCustomFields = new ArrayCollection();
$this->productCustomFields = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->composants = new ArrayCollection();
$this->pieces = new ArrayCollection();
$this->products = new ArrayCollection();
$this->customFields = new ArrayCollection();
$this->pieceCustomFields = new ArrayCollection();
$this->productCustomFields = new ArrayCollection();
$this->skeletonPieceRequirements = new ArrayCollection();
$this->skeletonProductRequirements = new ArrayCollection();
$this->skeletonSubcomponentRequirements = new ArrayCollection();
}
public function getName(): string
@@ -199,11 +188,6 @@ class ModelType
{
$this->category = $category;
if (null !== $this->pendingStructure) {
$this->applyStructureForCategory($this->pendingStructure, $category);
$this->pendingStructure = null;
}
return $this;
}
@@ -231,66 +215,34 @@ class ModelType
return $this;
}
public function getComponentSkeleton(): ?array
{
return $this->componentSkeleton;
}
public function setComponentSkeleton(?array $componentSkeleton): static
{
$this->componentSkeleton = $componentSkeleton;
return $this;
}
public function getPieceSkeleton(): ?array
{
return $this->pieceSkeleton;
}
public function setPieceSkeleton(?array $pieceSkeleton): static
{
$this->pieceSkeleton = $pieceSkeleton;
return $this;
}
public function getProductSkeleton(): ?array
{
return $this->productSkeleton;
}
public function setProductSkeleton(?array $productSkeleton): static
{
$this->productSkeleton = $productSkeleton;
return $this;
}
#[Groups(['model_type:read', 'product:read', 'composant:read', 'piece:read'])]
public function getStructure(): ?array
{
return match ($this->category) {
ModelCategory::COMPONENT => $this->componentSkeleton,
ModelCategory::PIECE => $this->pieceSkeleton,
ModelCategory::PRODUCT => $this->productSkeleton,
ModelCategory::COMPONENT => $this->getComponentStructureFromRelations(),
ModelCategory::PIECE => $this->getPieceStructureFromRelations(),
ModelCategory::PRODUCT => ['customFields' => $this->serializeCustomFields($this->productCustomFields)],
};
}
#[Groups(['model_type:write'])]
public function setStructure(?array $structure): static
{
if (!isset($this->category)) {
$this->pendingStructure = $structure;
return $this;
}
$this->applyStructureForCategory($structure, $this->category);
$this->pendingStructure = $structure;
return $this;
}
public function getPendingStructure(): ?array
{
return $this->pendingStructure;
}
public function clearPendingStructure(): void
{
$this->pendingStructure = null;
}
/**
* @return Collection<int, CustomField>
*/
@@ -315,41 +267,140 @@ class ModelType
return $this->productCustomFields;
}
public function getCreatedAt(): DateTimeImmutable
/**
* @return Collection<int, SkeletonPieceRequirement>
*/
public function getSkeletonPieceRequirements(): Collection
{
return $this->createdAt;
return $this->skeletonPieceRequirements;
}
public function getUpdatedAt(): DateTimeImmutable
public function addSkeletonPieceRequirement(SkeletonPieceRequirement $requirement): static
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
private function applyStructureForCategory(?array $structure, ModelCategory $category): void
{
if (ModelCategory::COMPONENT === $category) {
$this->componentSkeleton = $structure;
$this->pieceSkeleton = null;
$this->productSkeleton = null;
return;
if (!$this->skeletonPieceRequirements->contains($requirement)) {
$this->skeletonPieceRequirements->add($requirement);
$requirement->setModelType($this);
}
if (ModelCategory::PIECE === $category) {
$this->pieceSkeleton = $structure;
$this->componentSkeleton = null;
$this->productSkeleton = null;
return $this;
}
return;
public function removeSkeletonPieceRequirement(SkeletonPieceRequirement $requirement): static
{
$this->skeletonPieceRequirements->removeElement($requirement);
return $this;
}
/**
* @return Collection<int, SkeletonProductRequirement>
*/
public function getSkeletonProductRequirements(): Collection
{
return $this->skeletonProductRequirements;
}
public function addSkeletonProductRequirement(SkeletonProductRequirement $requirement): static
{
if (!$this->skeletonProductRequirements->contains($requirement)) {
$this->skeletonProductRequirements->add($requirement);
$requirement->setModelType($this);
}
$this->productSkeleton = $structure;
$this->componentSkeleton = null;
$this->pieceSkeleton = null;
return $this;
}
public function removeSkeletonProductRequirement(SkeletonProductRequirement $requirement): static
{
$this->skeletonProductRequirements->removeElement($requirement);
return $this;
}
/**
* @return Collection<int, SkeletonSubcomponentRequirement>
*/
public function getSkeletonSubcomponentRequirements(): Collection
{
return $this->skeletonSubcomponentRequirements;
}
public function addSkeletonSubcomponentRequirement(SkeletonSubcomponentRequirement $requirement): static
{
if (!$this->skeletonSubcomponentRequirements->contains($requirement)) {
$this->skeletonSubcomponentRequirements->add($requirement);
$requirement->setModelType($this);
}
return $this;
}
public function removeSkeletonSubcomponentRequirement(SkeletonSubcomponentRequirement $requirement): static
{
$this->skeletonSubcomponentRequirements->removeElement($requirement);
return $this;
}
private function getComponentStructureFromRelations(): array
{
$structure = ['customFields' => $this->serializeCustomFields($this->customFields), 'pieces' => [], 'products' => [], 'subcomponents' => []];
foreach ($this->skeletonPieceRequirements as $req) {
$structure['pieces'][] = [
'typePieceId' => $req->getTypePiece()->getId(),
];
}
foreach ($this->skeletonProductRequirements as $req) {
$structure['products'][] = [
'typeProductId' => $req->getTypeProduct()->getId(),
'familyCode' => $req->getFamilyCode(),
];
}
foreach ($this->skeletonSubcomponentRequirements as $req) {
$structure['subcomponents'][] = [
'alias' => $req->getAlias(),
'familyCode' => $req->getFamilyCode(),
'typeComposantId' => $req->getTypeComposant()?->getId(),
];
}
return $structure;
}
private function getPieceStructureFromRelations(): array
{
return [
'customFields' => $this->serializeCustomFields($this->pieceCustomFields),
'products' => array_map(fn (SkeletonProductRequirement $req) => [
'typeProductId' => $req->getTypeProduct()->getId(),
'familyCode' => $req->getFamilyCode(),
], $this->skeletonProductRequirements->toArray()),
];
}
/**
* @param Collection<int, CustomField> $fields
*/
private function serializeCustomFields(Collection $fields): array
{
$items = [];
foreach ($fields as $cf) {
$items[] = [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
];
}
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
return $items;
}
}

View File

@@ -14,6 +14,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\PieceRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -30,6 +31,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact', 'typePiece.name' => 'ipartial'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
#[ApiResource(
description: 'Pièces détachées du catalogue. Une pièce peut être rattachée à plusieurs machines et possède un type, des fournisseurs, des documents et un produit associé.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
@@ -44,6 +46,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class Piece
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['piece:read', 'document:list'])]
@@ -75,10 +79,6 @@ class Piece
#[Groups(['piece:read'])]
private ?Product $product = null;
#[ORM\Column(type: Types::JSON, nullable: true, name: 'productIds')]
#[Groups(['piece:read'])]
private ?array $productIds = null;
/**
* @var Collection<int, Constructeur>
*/
@@ -105,12 +105,32 @@ class Piece
#[Groups(['piece:read'])]
private Collection $customFieldValues;
/**
* @var Collection<int, Product>
*/
#[ORM\ManyToMany(targetEntity: Product::class, inversedBy: 'linkedPieces')]
#[ORM\JoinTable(name: 'piece_products')]
#[ORM\JoinColumn(name: 'piece_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
private Collection $products;
/**
* @var Collection<int, PieceProductSlot>
*/
#[ORM\OneToMany(targetEntity: PieceProductSlot::class, mappedBy: 'piece', cascade: ['persist', 'remove'], orphanRemoval: true)]
#[ORM\OrderBy(['position' => 'ASC'])]
private Collection $productSlots;
/**
* @var Collection<int, MachinePieceLink>
*/
#[ORM\OneToMany(mappedBy: 'piece', targetEntity: MachinePieceLink::class)]
private Collection $machineLinks;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
#[Groups(['piece:read'])]
private int $version = 1;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['piece:read'])]
private DateTimeImmutable $createdAt;
@@ -121,42 +141,16 @@ class Piece
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->constructeurs = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->customFieldValues = new ArrayCollection();
$this->products = new ArrayCollection();
$this->productSlots = new ArrayCollection();
$this->machineLinks = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
@@ -226,13 +220,8 @@ class Piece
{
$this->product = $product;
if ($product && empty($this->productIds)) {
$productId = $product->getId();
$this->productIds = $productId ? [$productId] : null;
}
if (!$product && empty($this->productIds)) {
$this->productIds = null;
if (null !== $product) {
$this->addProduct($product);
}
return $this;
@@ -241,46 +230,10 @@ class Piece
/**
* @return string[]
*/
#[Groups(['piece:read'])]
public function getProductIds(): array
{
if (!is_array($this->productIds)) {
return [];
}
return array_values(
array_filter(
array_map(
static fn ($value) => is_string($value) ? trim($value) : '',
$this->productIds,
),
static fn (string $value) => '' !== $value,
),
);
}
public function setProductIds(?array $productIds): static
{
if (!is_array($productIds)) {
$this->productIds = null;
return $this;
}
$normalized = array_values(
array_unique(
array_filter(
array_map(
static fn ($value) => is_string($value) ? trim($value) : '',
$productIds,
),
static fn (string $value) => '' !== $value,
),
),
);
$this->productIds = [] === $normalized ? null : $normalized;
return $this;
return $this->products->map(fn (Product $p) => $p->getId())->toArray();
}
/**
@@ -323,18 +276,64 @@ class Piece
return $this->customFieldValues;
}
public function getCreatedAt(): DateTimeImmutable
/**
* @return Collection<int, Product>
*/
public function getProducts(): Collection
{
return $this->createdAt;
return $this->products;
}
public function getUpdatedAt(): DateTimeImmutable
public function addProduct(Product $product): static
{
return $this->updatedAt;
if (!$this->products->contains($product)) {
$this->products->add($product);
}
return $this;
}
private function generateCuid(): string
public function removeProduct(Product $product): static
{
return 'cl'.bin2hex(random_bytes(12));
$this->products->removeElement($product);
return $this;
}
/**
* @return Collection<int, PieceProductSlot>
*/
public function getProductSlots(): Collection
{
return $this->productSlots;
}
public function addProductSlot(PieceProductSlot $slot): static
{
if (!$this->productSlots->contains($slot)) {
$this->productSlots->add($slot);
$slot->setPiece($this);
}
return $this;
}
public function removeProductSlot(PieceProductSlot $slot): static
{
$this->productSlots->removeElement($slot);
return $this;
}
public function getVersion(): int
{
return $this->version;
}
public function incrementVersion(): static
{
++$this->version;
return $this;
}
}

View 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;
}
}

View File

@@ -14,6 +14,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ProductRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -28,6 +29,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact', 'typeProduct.name' => 'ipartial'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt', 'supplierPrice'])]
#[ApiResource(
description: 'Produits du catalogue fournisseur. Un produit possède une référence, un prix indicatif, un type, des fournisseurs et des documents. Il peut être lié à des machines.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
@@ -42,6 +44,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
)]
class Product
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['product:read', 'document:list'])]
@@ -102,12 +106,22 @@ class Product
#[ORM\OneToMany(mappedBy: 'product', targetEntity: Composant::class)]
private Collection $composants;
/**
* @var Collection<int, Piece>
*/
#[ORM\ManyToMany(targetEntity: Piece::class, mappedBy: 'products')]
private Collection $linkedPieces;
/**
* @var Collection<int, MachineProductLink>
*/
#[ORM\OneToMany(mappedBy: 'product', targetEntity: MachineProductLink::class)]
private Collection $machineLinks;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
#[Groups(['product:read'])]
private int $version = 1;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['product:read'])]
private DateTimeImmutable $createdAt;
@@ -118,44 +132,17 @@ class Product
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->constructeurs = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->customFieldValues = new ArrayCollection();
$this->pieces = new ArrayCollection();
$this->composants = new ArrayCollection();
$this->linkedPieces = new ArrayCollection();
$this->machineLinks = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getName(): string
{
return $this->name;
@@ -244,18 +231,23 @@ class Product
return $this->customFieldValues;
}
public function getCreatedAt(): DateTimeImmutable
/**
* @return Collection<int, Piece>
*/
public function getLinkedPieces(): Collection
{
return $this->createdAt;
return $this->linkedPieces;
}
public function getUpdatedAt(): DateTimeImmutable
public function getVersion(): int
{
return $this->updatedAt;
return $this->version;
}
private function generateCuid(): string
public function incrementVersion(): static
{
return 'cl'.bin2hex(random_bytes(12));
++$this->version;
return $this;
}
}

View File

@@ -25,8 +25,9 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\UniqueConstraint(name: 'UNIQ_email', columns: ['email'])]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Profils utilisateurs. Chaque profil possède un rôle (Admin, Gestionnaire, Viewer, User), un email unique et un mot de passe. Gère l\'authentification par session.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new Get(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(security: "is_granted('ROLE_ADMIN')"),
new Post(
security: "is_granted('ROLE_ADMIN')",
@@ -103,7 +104,7 @@ class Profile implements UserInterface, PasswordAuthenticatedUserInterface
public function __construct()
{
$this->id = 'cl'.substr(strtolower(base_convert(random_bytes(12), 2, 36)), 0, 24);
$this->id = 'cl'.bin2hex(random_bytes(12));
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}

View File

@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\SiteRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -24,6 +25,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'sites')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Sites industriels. Chaque site regroupe des machines et peut avoir ses propres documents. Un site possède un nom, une adresse et des coordonnées de contact.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
@@ -37,6 +39,8 @@ use Symfony\Component\Validator\Constraints as Assert;
)]
class Site
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list'])]
@@ -62,6 +66,9 @@ class Site
#[ORM\Column(type: Types::STRING, length: 100, options: ['default' => ''], name: 'contactCity')]
private string $contactCity = '';
#[ORM\Column(type: Types::STRING, length: 7, options: ['default' => ''], name: 'color')]
private string $color = '';
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
@@ -81,41 +88,11 @@ class Site
private Collection $documents;
public function __construct()
{
$this->machines = new ArrayCollection();
$this->documents = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
// Générer un ID CUID-compatible si nécessaire
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
// Getters et Setters
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
$this->machines = new ArrayCollection();
$this->documents = new ArrayCollection();
}
public function getName(): string
@@ -190,14 +167,16 @@ class Site
return $this;
}
public function getCreatedAt(): DateTimeImmutable
public function getColor(): string
{
return $this->createdAt;
return $this->color;
}
public function getUpdatedAt(): DateTimeImmutable
public function setColor(string $color): static
{
return $this->updatedAt;
$this->color = $color;
return $this;
}
/**
@@ -259,10 +238,4 @@ class Site
return $this;
}
private function generateCuid(): string
{
// Génération d'un ID compatible CUID (format: cl + 24 caractères)
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Entity\Trait\CuidEntityTrait;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'skeleton_piece_requirements')]
#[ORM\HasLifecycleCallbacks]
class SkeletonPieceRequirement
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'skeletonPieceRequirements')]
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ModelType $modelType;
#[ORM\ManyToOne(targetEntity: ModelType::class)]
#[ORM\JoinColumn(name: 'typePieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ModelType $typePiece;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
private int $position = 0;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
public function getModelType(): ModelType
{
return $this->modelType;
}
public function setModelType(ModelType $modelType): static
{
$this->modelType = $modelType;
return $this;
}
public function getTypePiece(): ModelType
{
return $this->typePiece;
}
public function setTypePiece(ModelType $typePiece): static
{
$this->typePiece = $typePiece;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Entity\Trait\CuidEntityTrait;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'skeleton_product_requirements')]
#[ORM\HasLifecycleCallbacks]
class SkeletonProductRequirement
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'skeletonProductRequirements')]
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ModelType $modelType;
#[ORM\ManyToOne(targetEntity: ModelType::class)]
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ModelType $typeProduct;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
private ?string $familyCode = null;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
private int $position = 0;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
public function getModelType(): ModelType
{
return $this->modelType;
}
public function setModelType(ModelType $modelType): static
{
$this->modelType = $modelType;
return $this;
}
public function getTypeProduct(): ModelType
{
return $this->typeProduct;
}
public function setTypeProduct(ModelType $typeProduct): static
{
$this->typeProduct = $typeProduct;
return $this;
}
public function getFamilyCode(): ?string
{
return $this->familyCode;
}
public function setFamilyCode(?string $familyCode): static
{
$this->familyCode = $familyCode;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Entity\Trait\CuidEntityTrait;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'skeleton_subcomponent_requirements')]
#[ORM\HasLifecycleCallbacks]
class SkeletonSubcomponentRequirement
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'skeletonSubcomponentRequirements')]
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ModelType $modelType;
#[ORM\Column(type: Types::STRING, length: 255)]
private string $alias;
#[ORM\Column(type: Types::STRING, length: 255, name: 'familyCode')]
private string $familyCode;
#[ORM\ManyToOne(targetEntity: ModelType::class)]
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
private ?ModelType $typeComposant = null;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
private int $position = 0;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
public function getModelType(): ModelType
{
return $this->modelType;
}
public function setModelType(ModelType $modelType): static
{
$this->modelType = $modelType;
return $this;
}
public function getAlias(): string
{
return $this->alias;
}
public function setAlias(string $alias): static
{
$this->alias = $alias;
return $this;
}
public function getFamilyCode(): string
{
return $this->familyCode;
}
public function setFamilyCode(string $familyCode): static
{
$this->familyCode = $familyCode;
return $this;
}
public function getTypeComposant(): ?ModelType
{
return $this->typeComposant;
}
public function setTypeComposant(?ModelType $typeComposant): static
{
$this->typeComposant = $typeComposant;
return $this;
}
public function getPosition(): int
{
return $this->position;
}
public function setPosition(int $position): static
{
$this->position = $position;
return $this;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Entity\Trait;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
trait CuidEntityTrait
{
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
{
return $this->id;
}
public function setId(string $id): static
{
$this->id = $id;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -0,0 +1,463 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Entity\Product;
use App\Entity\Profile;
use App\Entity\Site;
use BackedEnum;
use DateTimeInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Error;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array;
use function is_object;
use function is_scalar;
use function method_exists;
abstract class AbstractAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function getSubscribedEvents(): array
{
return [Events::onFlush];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
$entityType = $this->entityType();
if ($this->hasCollectionTracking()) {
$this->onFlushComplex($em, $uow, $actorProfileId, $entityType);
} else {
$this->onFlushSimple($em, $uow, $actorProfileId, $entityType);
}
}
abstract protected function supports(object $entity): bool;
abstract protected function entityType(): string;
abstract protected function snapshotEntity(object $entity): array;
/**
* Override in subclasses that track custom field value changes.
* Return the owner entity if the CFV belongs to the tracked entity type.
*/
protected function getOwnerFromCustomFieldValue(CustomFieldValue $cfv): ?object
{
return null;
}
/**
* Whether this subscriber tracks constructeur collection changes.
* Override to return true for entities with a constructeurs ManyToMany.
*/
protected function hasCollectionTracking(): bool
{
return false;
}
protected function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}>
*/
protected function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
/**
* @param iterable<mixed> $items
*
* @return list<array{id: string, name: string}|string>
*/
protected function normalizeCollection(iterable $items): array
{
$entries = [];
$seen = [];
foreach ($items as $item) {
if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId();
if (null === $id || '' === $id || isset($seen[(string) $id])) {
continue;
}
$seen[(string) $id] = true;
if (method_exists($item, 'getName')) {
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
} else {
$entries[] = (string) $id;
}
}
}
return $entries;
}
protected function safeGet(object $entity, string $method): mixed
{
try {
return $entity->{$method}();
} catch (Error) {
return null;
}
}
protected function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof BackedEnum) {
return $value->value;
}
if ($value instanceof ModelType) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'code' => $value->getCode(),
];
}
if ($value instanceof Product) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'reference' => $value->getReference(),
];
}
if ($value instanceof Site || $value instanceof Machine || $value instanceof Composant || $value instanceof Piece) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
if ($value instanceof Collection) {
return $this->normalizeCollection($value);
}
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
if (is_array($value)) {
return $value;
}
return (string) $value;
}
/**
* @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra
*
* @return array<string, array{from:mixed, to:mixed}>
*/
protected function mergeDiffs(array $base, array $extra): array
{
foreach ($extra as $field => $change) {
$base[$field] = $change;
}
return $base;
}
protected function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
{
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
private function onFlushComplex(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
{
$pendingUpdates = [];
$pendingSnapshots = [];
$pendingEntities = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$entityId = (string) $entity->getId();
if ('' === $entityId) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$pendingUpdates[$entityId] = $this->mergeDiffs($pendingUpdates[$entityId] ?? [], $diff);
$pendingSnapshots[$entityId] = $this->snapshotEntity($entity);
$pendingEntities[$entityId] = $entity;
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$this->supports($entity)) {
continue;
}
$snapshot = $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);
foreach ($pendingUpdates as $entityId => $diff) {
if ([] === $diff) {
continue;
}
$entity = $pendingEntities[$entityId] ?? null;
if (null === $entity || !$this->supports($entity)) {
continue;
}
$snapshot = $pendingSnapshots[$entityId] ?? $this->snapshotEntity($entity);
$this->persistAuditLog($em, new AuditLog($entityType, $entityId, 'update', $diff, $snapshot, $actorProfileId));
}
}
private function collectCollectionUpdate(
object $collection,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingEntities,
): void {
if (!$collection instanceof PersistentCollection) {
return;
}
$owner = $collection->getOwner();
if (null === $owner || !$this->supports($owner)) {
return;
}
$ownerId = (string) $owner->getId();
if ('' === $ownerId) {
return;
}
$mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null;
if ('constructeurs' !== $fieldName) {
return;
}
$before = $this->normalizeCollection($collection->getSnapshot());
$after = $this->normalizeCollection($collection->toArray());
if ($before === $after) {
return;
}
$diff = [
'constructeurIds' => [
'from' => $before,
'to' => $after,
],
];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
$pendingSnapshots[$ownerId] = $this->snapshotEntity($owner);
$pendingEntities[$ownerId] = $owner;
}
private function collectCustomFieldValueChanges(
UnitOfWork $uow,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingEntities,
): void {
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof CustomFieldValue) {
continue;
}
$changeSet = $uow->getEntityChangeSet($entity);
if (!isset($changeSet['value'])) {
continue;
}
[$oldVal, $newVal] = $changeSet['value'];
if ($oldVal !== $newVal) {
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
}
}
private function trackCustomFieldValueChange(
CustomFieldValue $cfv,
mixed $from,
mixed $to,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingEntities,
): void {
$owner = $this->getOwnerFromCustomFieldValue($cfv);
if (null === $owner) {
return;
}
$ownerId = (string) $owner->getId();
if ('' === $ownerId) {
return;
}
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
$pendingSnapshots[$ownerId] = $this->snapshotEntity($owner);
$pendingEntities[$ownerId] = $owner;
}
}

View File

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

View File

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

View File

@@ -4,189 +4,36 @@ declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\Document;
use App\Entity\Machine;
use App\Entity\Piece;
use App\Entity\Product;
use App\Entity\Profile;
use App\Entity\Site;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_object;
use function is_scalar;
use function method_exists;
#[AsDoctrineListener(event: Events::onFlush)]
final class DocumentAuditSubscriber implements EventSubscriber
final class DocumentAuditSubscriber extends AbstractAuditSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
protected function supports(object $entity): bool
{
return $entity instanceof Document;
}
public function getSubscribedEvents(): array
protected function entityType(): string
{
return 'document';
}
protected function snapshotEntity(object $entity): array
{
return [
Events::onFlush,
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'filename' => $this->safeGet($entity, 'getFilename'),
'mimeType' => $this->safeGet($entity, 'getMimeType'),
'size' => $this->safeGet($entity, 'getSize'),
'machine' => $this->normalizeValue($this->safeGet($entity, 'getMachine')),
'composant' => $this->normalizeValue($this->safeGet($entity, 'getComposant')),
'piece' => $this->normalizeValue($this->safeGet($entity, 'getPiece')),
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
'site' => $this->normalizeValue($this->safeGet($entity, 'getSite')),
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotDocument(Document $document): array
{
return [
'id' => $document->getId(),
'name' => $document->getName(),
'filename' => $document->getFilename(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'machine' => $this->normalizeValue($document->getMachine()),
'composant' => $this->normalizeValue($document->getComposant()),
'piece' => $this->normalizeValue($document->getPiece()),
'product' => $this->normalizeValue($document->getProduct()),
'site' => $this->normalizeValue($document->getSite()),
];
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof Machine || $value instanceof Composant || $value instanceof Piece || $value instanceof Product || $value instanceof Site) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
return (string) $value;
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Events;
/**
* Keep the legacy single product relation in sync with the new productIds array.
* Keep the legacy single product relation in sync with the ManyToMany products collection.
*/
final class PieceProductSyncSubscriber implements EventSubscriber
{

View File

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

View File

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

View File

@@ -27,7 +27,14 @@ class DocumentStorageService
public function getAbsolutePath(string $relativePath): string
{
return $this->storageDir.'/'.$relativePath;
$absolutePath = $this->storageDir.'/'.$relativePath;
$realPath = realpath($absolutePath);
if (false !== $realPath && !str_starts_with($realPath, realpath($this->storageDir))) {
throw new RuntimeException(sprintf('Path traversal detected: "%s"', $relativePath));
}
return $absolutePath;
}
/**

View File

@@ -325,8 +325,6 @@ final class ModelTypeCategoryConversionService
$this->connection->executeStatement(
'UPDATE model_types
SET category = :cat,
componentskeleton = pieceskeleton,
pieceskeleton = NULL,
updatedat = :now
WHERE id = :id',
[
@@ -343,8 +341,8 @@ final class ModelTypeCategoryConversionService
{
// 1. Insert into pieces from composants
$count = $this->connection->executeStatement(
'INSERT INTO pieces (id, name, reference, prix, productids, typepieceid, productid, createdat, updatedat)
SELECT id, name, reference, prix, NULL, typecomposantid, productid, createdat, updatedat
'INSERT INTO pieces (id, name, reference, prix, typepieceid, productid, createdat, updatedat)
SELECT id, name, reference, prix, typecomposantid, productid, createdat, updatedat
FROM composants
WHERE typecomposantid = :id',
['id' => $modelTypeId],
@@ -395,8 +393,6 @@ final class ModelTypeCategoryConversionService
$this->connection->executeStatement(
'UPDATE model_types
SET category = :cat,
pieceskeleton = componentskeleton,
componentskeleton = NULL,
updatedat = :now
WHERE id = :id',
[

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\DTO\SyncConfirmation;
use App\DTO\SyncExecutionResult;
use App\DTO\SyncPreviewResult;
use App\Entity\ModelType;
use App\Service\Sync\SyncStrategyInterface;
use LogicException;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
class ModelTypeSyncService
{
/** @param iterable<SyncStrategyInterface> $strategies */
public function __construct(
#[AutowireIterator('app.sync_strategy')]
private readonly iterable $strategies,
) {}
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult
{
foreach ($this->strategies as $strategy) {
if ($strategy->supports($modelType)) {
return $strategy->preview($modelType, $newStructure);
}
}
throw new LogicException('No sync strategy found for category: '.$modelType->getCategory()->value);
}
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult
{
foreach ($this->strategies as $strategy) {
if ($strategy->supports($modelType)) {
return $strategy->execute($modelType, $confirmation);
}
}
throw new LogicException('No sync strategy found for category: '.$modelType->getCategory()->value);
}
}

View File

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

View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\CustomField;
use App\Entity\ModelType;
use App\Entity\SkeletonPieceRequirement;
use App\Entity\SkeletonProductRequirement;
use App\Entity\SkeletonSubcomponentRequirement;
use App\Enum\ModelCategory;
use Doctrine\ORM\EntityManagerInterface;
class SkeletonStructureService
{
public function __construct(private EntityManagerInterface $em) {}
public function updateSkeletonRequirements(ModelType $modelType, array $structure): void
{
// Clear existing requirements
foreach ($modelType->getSkeletonPieceRequirements() as $req) {
$modelType->removeSkeletonPieceRequirement($req);
}
foreach ($modelType->getSkeletonProductRequirements() as $req) {
$modelType->removeSkeletonProductRequirement($req);
}
foreach ($modelType->getSkeletonSubcomponentRequirements() as $req) {
$modelType->removeSkeletonSubcomponentRequirement($req);
}
// Create piece requirements
foreach (($structure['pieces'] ?? []) as $i => $pieceData) {
$req = new SkeletonPieceRequirement();
$req->setModelType($modelType);
$req->setTypePiece($this->em->getReference(ModelType::class, $pieceData['typePieceId']));
$req->setPosition($i);
$modelType->addSkeletonPieceRequirement($req);
}
// Create product requirements (shared by component + piece types)
foreach (($structure['products'] ?? []) as $i => $prodData) {
$req = new SkeletonProductRequirement();
$req->setModelType($modelType);
$req->setTypeProduct($this->em->getReference(ModelType::class, $prodData['typeProductId']));
$req->setFamilyCode($prodData['familyCode'] ?? null);
$req->setPosition($i);
$modelType->addSkeletonProductRequirement($req);
}
// Create subcomponent requirements (component types only)
foreach (($structure['subcomponents'] ?? []) as $i => $subData) {
$req = new SkeletonSubcomponentRequirement();
$req->setModelType($modelType);
$req->setAlias($subData['alias'] ?? '');
$req->setFamilyCode($subData['familyCode'] ?? '');
if (!empty($subData['typeComposantId'])) {
$req->setTypeComposant($this->em->getReference(ModelType::class, $subData['typeComposantId']));
}
$req->setPosition($i);
$modelType->addSkeletonSubcomponentRequirement($req);
}
// Update custom field definitions
$this->updateCustomFields($modelType, $structure['customFields'] ?? []);
}
/**
* Sync CustomField entities for this ModelType.
* Handles two frontend formats:
* - COMPONENT: {key, value: {type, required, options?, defaultValue?}, id?, customFieldId?}
* - PIECE/PRODUCT: {name, type, required, options?, orderIndex?, defaultValue?}.
*/
private function updateCustomFields(ModelType $modelType, array $proposedFields): void
{
// Determine which FK to use based on category
$category = $modelType->getCategory();
$fkField = match ($category) {
ModelCategory::COMPONENT => 'typeComposant',
ModelCategory::PIECE => 'typePiece',
ModelCategory::PRODUCT => 'typeProduct',
};
// Load existing custom fields
$existingFields = $this->em->getRepository(CustomField::class)->findBy(
[$fkField => $modelType],
['orderIndex' => 'ASC']
);
// Index existing by ID for matching
$existingById = [];
foreach ($existingFields as $cf) {
$existingById[$cf->getId()] = $cf;
}
$processedIds = [];
foreach ($proposedFields as $i => $fieldData) {
// Normalize both formats to a common shape
$normalized = $this->normalizeCustomFieldData($fieldData, $i);
// Try to match an existing field by ID
$existingField = null;
$fieldId = $fieldData['customFieldId'] ?? $fieldData['id'] ?? null;
if ($fieldId && isset($existingById[$fieldId])) {
$existingField = $existingById[$fieldId];
}
if ($existingField) {
// Update existing field
$existingField->setName($normalized['name']);
$existingField->setType($normalized['type']);
$existingField->setRequired($normalized['required']);
$existingField->setOptions($normalized['options']);
$existingField->setDefaultValue($normalized['defaultValue']);
$existingField->setOrderIndex($normalized['orderIndex']);
$processedIds[$existingField->getId()] = true;
} else {
// Create new field
$cf = new CustomField();
$cf->setName($normalized['name']);
$cf->setType($normalized['type']);
$cf->setRequired($normalized['required']);
$cf->setOptions($normalized['options']);
$cf->setDefaultValue($normalized['defaultValue']);
$cf->setOrderIndex($normalized['orderIndex']);
match ($category) {
ModelCategory::COMPONENT => $cf->setTypeComposant($modelType),
ModelCategory::PIECE => $cf->setTypePiece($modelType),
ModelCategory::PRODUCT => $cf->setTypeProduct($modelType),
};
$this->em->persist($cf);
}
}
// Remove orphaned fields (exist in DB but not in proposed)
foreach ($existingFields as $cf) {
if (!isset($processedIds[$cf->getId()])) {
$this->em->remove($cf);
}
}
}
/**
* Normalize frontend custom field data to a common shape.
*
* @return array{name: string, type: string, required: bool, options: ?array, defaultValue: ?string, orderIndex: int}
*/
private function normalizeCustomFieldData(array $fieldData, int $index): array
{
// COMPONENT format: {key: "name", value: {type, required, options?, defaultValue?}}
if (isset($fieldData['key'], $fieldData['value'])) {
$value = $fieldData['value'];
return [
'name' => $fieldData['key'],
'type' => $value['type'] ?? 'text',
'required' => (bool) ($value['required'] ?? false),
'options' => $value['options'] ?? null,
'defaultValue' => $value['defaultValue'] ?? null,
'orderIndex' => $index,
];
}
// PIECE/PRODUCT format: {name, type, required, options?, orderIndex?, defaultValue?}
return [
'name' => $fieldData['name'] ?? '',
'type' => $fieldData['type'] ?? 'text',
'required' => (bool) ($fieldData['required'] ?? false),
'options' => $fieldData['options'] ?? null,
'defaultValue' => $fieldData['defaultValue'] ?? null,
'orderIndex' => $fieldData['orderIndex'] ?? $index,
];
}
}

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