Compare commits

..

122 Commits

Author SHA1 Message Date
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
Matthieu
51248b7854 chore(release) : v1.8.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:27:29 +01:00
Matthieu
0e11f4ad2d refactor(api) : remove TypeMachine skeleton system, fix ModelType serialization
- Remove TypeMachine, TypeMachineComponentRequirement, TypeMachinePieceRequirement,
  TypeMachineProductRequirement entities and related repositories/state processor
- Replace MachineSkeletonController with MachineStructureController
- Link CustomField directly to Machine instead of TypeMachine
- Add migration to drop TypeMachine tables and migrate custom fields to machines
- Fix ModelType serialization: Annotation\Groups → Attribute\Groups (Symfony 8 compat)
  and add product:read, composant:read, piece:read groups for embedded category display
- Fix Profile: same Annotation → Attribute import
- Fix SearchFilter: partial → ipartial on Comment and Document
- Update frontend submodule (remove skeleton pages/components, simplify machine creation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:26:16 +01:00
Matthieu
f2539099bc chore(frontend) : update submodule — DataTable global + filtres server-side
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:07:03 +01:00
Matthieu
e5dc60467e feat(api) : ajout filtres SearchFilter ipartial sur noms de types et commentaires
- Piece : typePiece.name ipartial
- Composant : typeComposant.name ipartial
- Product : typeProduct.name ipartial + OrderFilter supplierPrice
- Comment : entityName partial + OrderFilter authorName, status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 16:05:24 +01:00
Matthieu
fbc0372bd6 docs(readme) : comprehensive project documentation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:49:35 +01:00
Matthieu
1483b0075b chore(frontend) : update submodule — README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:45:53 +01:00
Matthieu
74e88923dc chore(frontend) : update submodule — README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:38:19 +01:00
Matthieu
ef61d1a0d3 chore : remove obsolete docs and update submodule
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:10:40 +01:00
Matthieu
3f0fb0d5c2 chore : remove stale TODO.md and temp files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:06:08 +01:00
Matthieu
dd1497beac chore : bump v1.8.0, update changelog, gitignore and submodule
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:01:00 +01:00
Matthieu
7cd8772617 chore(frontend) : update submodule — navbar reorder and icons
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:31:36 +01:00
Matthieu
d89c97f0a0 feat(documents) : filesystem storage, server-side pagination and PDF compression
- Add DocumentStorageService for file-based storage (replaces Base64 in DB)
- Add DocumentServeController with /file and /download endpoints
- Add DocumentUploadProcessor using FormData + filesystem storage
- Add DocumentNormalizer exposing fileUrl/downloadUrl on all responses
- Add DocumentFileCleanupListener for automatic file deletion
- Add MigrateDocumentsToFilesystemCommand (Base64 → files, memory-safe)
- Add ApiFilter (SearchFilter, ExistsFilter, OrderFilter) on Document entity
- Add PdfCompressorService + refactor CompressPdfCommand for batch processing
- Fix TypeMachine PUT: deserialize=false + validate=false to prevent
  UniqueEntity false positive and writableLink collection interference
- Update CHANGELOG for v1.8.0
- Update frontend submodule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:18:55 +01:00
Matthieu
7a5dd0b555 feat(skeleton) : add custom PUT processor and edit guard for linked machines
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:13:45 +01:00
Matthieu
44d69db560 chore(frontend) : update submodule — description field on catalog forms
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:35:57 +01:00
Matthieu
453065c9f0 feat(entities) : add description field to Piece and Composant
Add nullable TEXT description column to both pieces and composants
tables with corresponding Doctrine entity mappings, getters/setters
and serialization groups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:35:37 +01:00
Matthieu
eb85323116 chore(frontend) : update submodule — fix site edit modal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:33:34 +01:00
Matthieu
2dfa501a65 fix(sites) : add PATCH operation and fix migration constraint drop
Add Patch operation to Site entity (was only Put, causing 405 errors).
Fix migration to use ALTER TABLE DROP CONSTRAINT instead of DROP INDEX
for the piece name unique constraint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:33:22 +01:00
Matthieu
c22f9dbf2b chore(release) : bump version to 1.7.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:36:58 +01:00
Matthieu
27a1b09d62 chore(frontend) : update submodule — comments system and constructeur fixes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:06:31 +01:00
Matthieu
7bbb693924 feat(comments) : add comment entity, controller and migration
Create Comment entity with API Platform annotations (GET, PATCH, DELETE).
Add CommentController with POST (create), PATCH (resolve) and GET
(unresolved count) endpoints. Add migration for comments table and
piece reference unique index.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:06:25 +01:00
Matthieu
9661fd5d91 fix(entities) : add unique constraints for constructeur name and piece reference
Add UniqueEntity validation on Constructeur.name and Piece.reference.
Move unique DB constraint from piece name to piece reference column.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:06:19 +01:00
Matthieu
d9ab583879 chore(frontend) : update submodule — package-lock.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:02:17 +01:00
Matthieu
5d41bda997 fix(ui) : replace checkbox with toggle switch for boolean custom fields
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:56:56 +01:00
Matthieu
3d037083c6 feat(ui) : display role badge in profile dropdown
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:42:09 +01:00
Matthieu
a3e440c254 feat(permissions) : add role-based access control system
Backend:
- Add role hierarchy (ADMIN > GESTIONNAIRE > VIEWER > USER) in security.yaml
- Add password authentication on profile activation (SessionProfileController)
- Add SessionProfileAuthenticator with stateless API firewall
- Add ProfilePasswordHasher state processor for API Platform
- Add security annotations on all 18 API Platform entities
- Add denyAccessUnlessGranted on all 13 custom controllers
- Add AdminProfileController for profile/role management (/api/admin/profiles)
- Add InitProfilePasswordsCommand for initial admin setup
- Simplify SessionProfilesController to list-only (removed create/delete)

Frontend (submodule update):
- Add usePermissions composable (isAdmin, canEdit, canView, isGranted)
- Add password login modal on profiles page
- Add admin backoffice page for profile management
- Disable all form fields for ROLE_VIEWER across all edit/create pages
- Show navigation buttons for all roles, hide destructive actions for viewers
- Add readonly mode to ModelTypeForm and site/constructeur modals
- Guard /admin routes in middleware
- Configure Vite proxy for API requests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:37:12 +01:00
Matthieu
adc44b99d3 fix(machines) : fix skeleton creation — pagination, duplication, custom fields
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:40:09 +01:00
Matthieu
60afeb4cfd chore(frontend) : update submodule — Playwright e2e setup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 09:07:37 +01:00
Matthieu
02ff8b1a96 feat(audit) : extend audit logging to machines, constructeurs, model types, documents and conversions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:51:26 +01:00
Matthieu
2156df22c6 chore(release) : bump version to 1.6.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:27:47 +01:00
Matthieu
cd2a3fac55 feat(categories) : add bidirectional piece/component category conversion
Backend service and controller for converting piece categories to component
categories (and vice-versa). Uses raw SQL in a transaction to preserve IDs
and transfer all related data (documents, custom fields, constructeurs).
Includes php-cs-fixer formatting pass on existing controllers/entities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:27:07 +01:00
Matthieu
6300a3588a chore(docker) : replace pgAdmin with Adminer for lighter DB management
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 12:10:52 +01:00
Matthieu
45213103e4 Merge branch 'develop' into master — fix documents OOM 2026-02-11 17:16:41 +01:00
Matthieu
91b8b424d6 fix(documents) : add serialization groups to prevent OOM on collection endpoint
The path field (base64 data URIs) is now excluded from GetCollection
via document:list group. Individual GET returns path via document:detail
group. Related entities expose id+name in document:list for attachment
display. Frontend lazy-loads path on download/preview click.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:16:27 +01:00
Matthieu
0d1c9277e5 Merge branch 'develop' into master — changelog page 2026-02-11 17:01:53 +01:00
Matthieu
db16d26103 chore(frontend) : update submodule — changelog page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:01:45 +01:00
Matthieu
0eb64d0975 Merge branch 'develop' into master — v1.5.0 2026-02-11 16:51:22 +01:00
Matthieu
39e503ae18 chore(release) : bump version to 1.5.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:50:59 +01:00
Matthieu
70ed354c42 Merge branch 'fix/filtres-listes' into develop 2026-02-11 16:50:48 +01:00
Matthieu
ba98ae37f4 feat(entity) : auto-capitalize first letter of names on Composant and ModelType
Update setName() to use mb_strtoupper on the first character so that
category and component names always start with an uppercase letter.
Also update frontend submodule with URL state preservation and back
button improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:48:46 +01:00
Matthieu
906d39793f fix(filters) : repair broken filters on catalog and document pages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:33:20 +01:00
Matthieu
f970c1928d fix(api) : cap pagination to 200 items/page to prevent OOM in production
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:11:09 +01:00
Matthieu
2a1d966b87 chore(frontend) : update submodule — smart cache on composables
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:18:50 +01:00
Matthieu
a393b62e9f chore(frontend) : update submodule — Malio brand colors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 09:06:30 +01:00
Matthieu
1247f72af6 chore(frontend) : update submodule — activity log + clickable catalog types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:54:26 +01:00
Matthieu
6735bf252c feat(activity-log) : add paginated activity log endpoint and store constructeur names in audit
- New GET /api/activity-logs endpoint with pagination and filters
  (entityType, action) for the global activity log page
- Add findAllPaginated() to AuditLogRepository
- normalizeCollection() now stores {id, name} objects instead of
  bare IDs so constructeur changes display readable names

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 08:54:19 +01:00
Matthieu
508066d39f fix(frontend) : update submodule with custom field display fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:48:12 +01:00
Matthieu
70956c204e fix(audit) : inject Security for actor resolution + track custom field changes
- Inject Security service into all 3 audit subscribers to resolve
  actor profile from authenticated user (fixes "Par Inconnu" issue)
- Add CustomFieldValue tracking: insertions, updates, and deletions
  on custom field values now produce audit log entries on the parent
  entity (composant, piece, product) with field name prefix
  "customField:{name}"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 16:48:07 +01:00
Matthieu
16a7eac0c6 chore(release) : v1.4.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:59:55 +01:00
Matthieu
37ac08b182 chore(frontend) : update submodule — edit pages optimization
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:58:50 +01:00
Matthieu
5ef80b362e perf(api) : add serialization groups to CustomFieldValue and CustomField
Expose customField definitions (id, name, type, required, options, orderIndex)
inline in entity responses, eliminating separate API calls for custom field data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 15:58:43 +01:00
Matthieu
78f19daf76 chore(release) : v1.3.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:20:29 +01:00
Matthieu
6caa4a61df chore(frontend) : update submodule — API optimizations, cache invalidation, tests
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 14:19:24 +01:00
Matthieu
bf55034b2e chore(frontend) : complete frontend refactoring (F1-F7)
Update frontend submodule with 14 conventional commits covering:
- F1.1-F1.4: Decompose mega-components (machine detail/create, ComponentItem/PieceItem)
- F2.1-F2.3: Extract shared helpers (extractCollection, history, types)
- F3.2-F3.3: Migrate composables to TypeScript, eliminate explicit any
- F4.1-F4.2: Enable strict ESLint rules, remove debug console.logs
- F5.1: Split modelUtils into thematic modules
- F6.1-F6.2: Configure Vitest with 54 unit tests
- F7.2-F7.3: DaisyUI confirm modal, extract AppNavbar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:20:55 +01:00
Matthieu
ba1114e78b chore(frontend): update refactor plan and remove legacy frontend 2026-02-06 17:17:29 +01:00
Matthieu
5ccc3b30f0 docs : add comprehensive refactoring plan (backend + frontend)
14 phases, 39 tasks covering:
- Backend: security, code duplication, controllers, storage, tests
- Frontend: mega-component split, duplication, TypeScript migration, tests

Includes agent tracking system with status, journal, and rules.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:34:43 +01:00
8d83076be0 chore(release) : v1.2.0 2026-01-29 19:55:29 +01:00
Matthieu
997a3ae822 chore(frontend): update submodule to history UI 2026-01-25 21:22:24 +01:00
Matthieu
034c193e4b feat(audit): add history tracking and bump version to 1.1.2 2026-01-25 21:19:42 +01:00
Matthieu
4acc8d1c01 chore(release) : v1.1.1
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:07:45 +01:00
Matthieu
49ff15f18d fix : correct commit message format in release script
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:07:08 +01:00
Matthieu
7a02617d48 chore : add qpdf to Docker image for PDF compression
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:06:08 +01:00
Matthieu
e52eef0491 docs : add PDF compression documentation to README
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:03:36 +01:00
Matthieu
a5118305d3 feat : automatic PDF compression on upload
- Add PdfCompressorService for lossless compression with qpdf
- Add DocumentPdfCompressorListener for automatic compression on persist/update
- Add app:compress-pdf command for batch compression of existing PDFs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:02:26 +01:00
Matthieu
b51671b1d4 chore(release) : v1.1.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 15:58:09 +01:00
Matthieu
1643dcf8c2 fix : case-insensitive search filters for all entities 2026-01-25 15:54:07 +01:00
Matthieu
17ab4cdd16 chore : update fixtures with current database data 2026-01-25 15:44:22 +01:00
Matthieu
d9182131d9 chore : reset migrations to single initial schema + add deployment guide 2026-01-25 15:41:06 +01:00
Matthieu
26a7fe64be chore : update frontend submodule to master v1.0.0 2026-01-25 12:07:15 +01:00
Matthieu
4669dec359 chore : add versioning system and release script 2026-01-25 12:02:20 +01:00
Matthieu
3f05fe753e Update frontend submodule for piece requirements and component selections 2026-01-25 11:40:48 +01:00
Matthieu
a502acd234 Support multiple product requirements on pieces 2026-01-25 11:40:41 +01:00
Matthieu
69b199b6dc Update frontend submodule: overview machines + lighter PDF previews 2026-01-25 09:46:23 +01:00
Matthieu
d5f6749f9e wip: api filters and migration helpers 2026-01-23 23:30:05 +01:00
ad7918c993 chore(frontend) : met a jour le sous-module 2026-01-23 19:35:36 +01:00
86447000b1 feat(api) : ajoute pagination et filtres pour les catalogues 2026-01-23 19:35:26 +01:00
7da5eb917a feat(fixtures): ajouter système de fixtures pour les données
- fixtures/data.sql: dump des données actuelles de la base
- fixtures/load.sh: script shell de chargement
- makefile: targets fixtures-dump, fixtures-load, fixtures-reset

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 13:34:07 +01:00
d65eb9ff0f WIP: corrections sérialisation API et script normalisation SQL
Backend:
- Fix Groups sur TypeMachine*Requirement: exposer typePiece/typeComposant/typeProduct
- Fix Groups sur Document, Piece, Product, Composant pour sérialisation
- Add addConstructeur/removeConstructeur sur Piece et Product

Scripts:
- Fix normalize-dump.py: gérer les schémas quotés ("public"."table")

Frontend (sous-module):
- Corrections formulaires et sérialisation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:34:00 +01:00
895df7415b chore(frontend): maj sous-module 2026-01-15 13:43:33 +01:00
9abe9fea7f chore(migrations): normaliser le schema 2026-01-15 13:42:36 +01:00
ea45ce9d0a fix(schema): aligner mappings et sortie machine 2026-01-15 13:42:29 +01:00
2c3fbb093a feat(modeles): exposer la structure selon la categorie 2026-01-15 13:42:18 +01:00
3c5fb83673 docs: maj migration et carnet de bord 2026-01-15 13:39:06 +01:00
e1dc8850c0 feat(make): nettoyer le cache Symfony 2026-01-15 13:38:55 +01:00
59622580a9 chore(frontend): maj sous-module 2026-01-15 12:51:38 +01:00
bdd1837247 chore(frontend): mettre a jour le sous-module 2026-01-14 23:11:05 +01:00
40b4b90ed8 wip(api) : machine skeleton + type links 2026-01-12 13:14:19 +01:00
d4bdb76fda docs: ajouter la liaison INV-20260111-02 [INV-20260111-02] 2026-01-11 17:14:53 +01:00
f7fc1bdee2 chore(repo): maj du submodule frontend [INV-20260111-02] 2026-01-11 17:14:38 +01:00
1a751927fa docs: tracer la liaison INV-20260111-01 [INV-20260111-01] 2026-01-11 17:12:46 +01:00
987aa5c15f chore(repo): lier Inventory_frontend en submodule [INV-20260111-01] 2026-01-11 17:12:34 +01:00
d2a1cd0cc4 docs: mettre a jour le plan et le carnet de bord 2026-01-11 17:06:36 +01:00
5222a6bbf9 chore(config): ajuster docker, cors et securite 2026-01-11 17:06:25 +01:00
15e0b23f15 chore(migration): ajouter scripts et migrations de donnees 2026-01-11 17:05:50 +01:00
fab1d25871 feat(api): ajouter les endpoints session, documents, champs et squelette 2026-01-11 17:05:41 +01:00
037ed782a7 feat(api): ajouter les entites et repositories inventory 2026-01-11 17:05:36 +01:00
de8b05a553 docs : mise a jour carnet de bord Session 2
- Resolution probleme Apache
- JWT 100% operationnel
- Reorganisation projet terminee
- Etat des lieux complet dans MIGRATION_PLAN
- 673 lignes de donnees a migrer identifiees

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 19:42:30 +01:00
6f9e1ec626 chore(config) : mise a jour configuration Symfony et Docker
- Installation bundles: lexik/jwt, vich/uploader, symfony/uid
- Configuration Docker avec pgAdmin
- Variables environnement pour JWT et PostgreSQL
- VirtualHost Apache pour Symfony public/

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 19:42:01 +01:00
8430e9baef docs : ajout documentation migration et reorganisation projet
- Plan de migration detaille (Prisma vers Doctrine)
- Carnet de bord avec etat des lieux Phase 1-2
- Mise a jour CHANGELOG et README
- Reorganisation structure projet (frontend/)
- Gitignore pour archives et frontend

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 19:41:55 +01:00
fca3104a39 feat(api) : configuration API Platform avec JWT
- Configuration security.yaml avec firewalls JWT
- Routes API Platform avec prefixe /api
- Controller de test pour validation setup
- htaccess pour mod_rewrite Apache
- Access control pour routes publiques/protegees

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 19:41:48 +01:00
50336694f6 feat(auth) : creation entite Profile avec JWT
- Entite Profile implementant UserInterface
- Support authentification JWT via email/password
- Repository avec PasswordUpgraderInterface
- Migration Doctrine pour table profiles
- Script utilitaire creation utilisateur test

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 19:41:38 +01:00
c99f76d755 feat(infra) : ajout de pgAdmin avec configuration auto
- Ajout du service pgAdmin dans docker-compose.yml
- Configuration serveur PostgreSQL pre-enregistre (servers.json)
- Fichier pgpass pour authentification automatique
- Port 5050 expose pour acces web
- Configuration lexik/jwt-authentication-bundle

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 19:41:32 +01:00
169 changed files with 19620 additions and 12545 deletions

3
.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 ###
@@ -39,3 +39,4 @@ DEFAULT_URI=http://localhost
###> nelmio/cors-bundle ###
CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
###< nelmio/cors-bundle ###

26
.gitignore vendored
View File

@@ -23,3 +23,29 @@
###> docker ###
docker/.env.docker.local
###< docker ###
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###
###> migration archives ###
/_archives/
###< migration archives ###
###> temp files ###
*.sql
*.har
FEATURE_IDEAS.md
###< temp files ###
###> frontend ###
/frontend/
###< frontend ###
###> ide ###
/.idea/
###< ide ###
###> wsl ###
*:Zone.Identifier
###< wsl ###

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "Inventory_frontend"]
path = Inventory_frontend
url = gitea@gitea.malio.fr:MALIO-DEV/Inventory_frontend.git

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/

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>

144
.idea/ferme.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,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

@@ -1,15 +1,78 @@
# Changelog
Liste des évolutions du projet Ferme
## [1.8.1] - 2026-03-05
## [0.0.0]
### Parameters
Ajouter dans le fichier .env
- DEFAULT_URI
- DATABASE_URL
### Ajouts
- **Composant DataTable generique** : nouveau composant `DataTable.vue` + composable `useDataTable.ts` avec tri, recherche, pagination et filtres server-side. Toutes les pages catalogue (composants, pieces, produits, documents, constructeurs, commentaires, journal d'audit, admin) migrees vers ce composant partage.
- **Messages d'erreur humanises** : les erreurs backend (violations de contraintes, erreurs serveur) sont desormais traduites en messages comprehensibles pour l'utilisateur final (`errorMessages.ts`).
- **Icones Lucide dans la navbar** : reorganisation des groupes de navigation et ajout d'icones pour chaque section.
- **Modal d'ajout d'entites aux machines** (`AddEntityToMachineModal.vue`) : ajout direct de composants, pieces et produits depuis la fiche machine.
- **Filtres SearchFilter ipartial** sur les noms de types de modeles et commentaires cote API.
### Added
### Refactoring
- **Suppression du systeme TypeMachine (squelettes machines)** : les entites `TypeMachine`, `TypeMachineComponentRequirement`, `TypeMachinePieceRequirement`, `TypeMachineProductRequirement` sont supprimees avec leurs repositories et state processors. Les champs personnalises machines sont desormais lies directement a chaque machine (relation `CustomField → Machine`).
- **Suppression des pages squelettes machines** : pages `/machine-skeleton`, `/type/[id]`, `/type/edit/[id]` et tous les composants associes (`TypeEditForm`, `MachineSkeletonSummary`, `MachineCreatePreview`, selectors de requirements, `useMachineTypesApi`, `useMachineSkeletonEditor`, `useMachineCreateSelections`, `useMachineCreatePreview`).
- **Simplification de la creation de machines** : plus besoin de selectionner un squelette, ajout direct de composants/pieces/produits.
- **Refactoring MachineStructureController** : remplacement de `MachineSkeletonController` par `MachineStructureController` avec gestion directe de la structure machine.
- **Migration de toutes les tables vers DataTable** : suppression du code de tableau duplique dans chaque page au profit du composant generique.
### Changed
### Corrections
- **Suppression catalogue avec confirmation** : la suppression d'une piece ou d'un composant dans le catalogue affiche desormais une modale de confirmation listant les elements qui seront supprimes en cascade (documents, liaisons machine, valeurs de champs personnalises) au lieu de bloquer la suppression.
- **Fix affichage categorie sur les pages edit** : les categories (produit, composant, piece) s'affichent correctement sur les pages d'edition au lieu de "Categorie inconnue". Cause : import `Serializer\Annotation\Groups` obsolete dans `ModelType` (remplace par `Attribute\Groups` pour Symfony 8) + groupes de serialisation manquants (`product:read`, `composant:read`, `piece:read`).
- Fix import `Serializer\Annotation\Groups``Attribute\Groups` dans `Profile`.
- Fix filtre `SearchFilter` : `partial``ipartial` sur `Comment.entityName` et `Document.name`/`Document.filename` pour recherche insensible a la casse.
### Fixed
### Migration requise
```bash
docker compose exec web php bin/console doctrine:migrations:migrate
```
## [1.8.0] - 2026-03-03
### Ajouts
- **Stockage documents sur disque** : les documents sont desormais stockes en fichiers sur le systeme de fichiers au lieu de Base64 en base de donnees. Les endpoints `/api/documents/{id}/file` et `/api/documents/{id}/download` servent les fichiers directement.
- **Commande de migration** `app:migrate-documents-to-filesystem` : migre les documents existants (Base64 → fichiers) avec dry-run, batch-size et limit.
- **Pagination serveur sur la page Documents** : recherche, tri (date/nom/taille), filtre par rattachement (site/machine/composant/piece/produit), selecteur par page (20/50/100).
- **Compression PDF automatique** : les documents PDF uploades sont compresses automatiquement via Ghostscript. Commande `app:compress-pdf` pour compresser les PDFs existants.
- **Nettoyage automatique des fichiers** : suppression du fichier sur disque lors de la suppression d'un document.
- **Champ description** sur les entites Piece et Composant, visible dans les catalogues avec popover au survol.
### Corrections
- Fix normalisation des documents : `fileUrl` et `downloadUrl` toujours exposes dans l'API (meme sans `path` dans le groupe de serialisation).
- Fix recursion infinie dans `DocumentNormalizer` (`getSupportedTypes` retourne `false` pour desactiver le cache).
- Fix edition de squelettes machines : `deserialize: false` + `validate: false` sur le PUT pour eviter le conflit UniqueEntity et l'interference du deserialiseur avec les collections writableLink.
- Fix sites : ajout operation PATCH et correction migration contrainte.
- Retrocompatibilite : le controleur de service gere transparentement les anciens documents Base64 et les nouveaux fichiers.
### Migration requise
```bash
docker compose exec php php bin/console doctrine:migrations:migrate
docker compose exec php php bin/console app:migrate-documents-to-filesystem
```
## [1.7.0] - 2026-03-02
### Ajouts
- **Systeme de commentaires / tickets** : les utilisateurs peuvent laisser des commentaires sur les fiches (machines, pieces, composants, produits, categories, squelettes). Les gestionnaires peuvent les resoudre.
- **Page commentaires** (`/comments`) : vue centralisee avec filtres (statut, type d'entite), pagination et liens cliquables vers les fiches.
- **Badge notifications** : compteur de commentaires ouverts sur l'avatar utilisateur et dans le menu profil (polling 60s).
- **Controle d'acces par roles** : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages.
- **Badge de role** dans le dropdown du profil utilisateur.
- **Journal d'audit etendu** : audit logging sur machines, constructeurs, types de modeles, documents et conversions.
- **Commande `app:init-profile-passwords`** : initialisation en masse des mots de passe et roles.
### Corrections
- Toggle switch pour les champs personnalises booleens (remplace les checkboxes).
- Recherche constructeur : filtrage cote client au lieu d'appels API debounce.
- Prevention des doublons de noms de constructeurs et de references de pieces (contraintes unique).
- Fix creation de squelettes machines : pagination, duplication, champs personnalises.
### Migration requise
```bash
docker compose exec php php bin/console doctrine:migrations:migrate
docker compose exec php php bin/console app:init-profile-passwords
```
## [1.6.0] - 2026-02-12
- Version initiale avec gestion du parc machines, pieces, composants, produits et categories.

203
CLAUDE.md Normal file
View File

@@ -0,0 +1,203 @@
# CLAUDE.md — Inventory Project
## Project Overview
Application de gestion d'inventaire industriel (machines, pièces, composants, produits).
Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
## Stack
| Layer | Tech | Version |
|-------|------|---------|
| Backend | Symfony + API Platform | 8.0 / ^4.2 |
| PHP | PHP | >=8.4 |
| Database | PostgreSQL | 16 |
| Frontend | Nuxt (SPA, SSR off) | 4 |
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 |
| CSS | TailwindCSS 4 + DaisyUI 5 | |
| Auth | Session-based (cookies, pas JWT) | |
| Containers | Docker Compose | |
## Project Structure
```
Inventory/ # Backend Symfony (repo principal)
├── src/Entity/ # Entités Doctrine (annotations PHP 8 attributes)
├── src/Controller/ # Controllers custom (session, comments, audit…)
├── src/EventSubscriber/ # Audit subscribers (onFlush)
├── config/ # Config Symfony
├── migrations/ # Migrations Doctrine (raw SQL PostgreSQL)
├── docker/ # Dockerfile + .env.docker
├── scripts/ # release.sh, normalize-dump.py
├── fixtures/ # SQL fixtures
├── tests/ # PHPUnit
├── pre-commit, commit-msg # Git hooks
├── makefile # Commandes Docker/dev
├── VERSION # Source unique de version (semver)
├── Inventory_frontend/ # ← SUBMODULE GIT (repo séparé)
│ ├── app/pages/ # Pages Nuxt (file-based routing)
│ ├── app/components/ # Composants Vue (auto-imported)
│ ├── app/composables/ # Composables Vue
│ ├── app/shared/ # Types, utils, validation
│ ├── app/middleware/ # Auth middleware global
│ └── app/services/ # Service layer (wrappers useApi)
```
## Key Commands
```bash
# Docker
make start # Démarrer les containers
make stop # Arrêter
make shell # Shell interactif (nécessite un TTY)
make install # Install complet (composer + npm + build)
# Backend
make test # PHPUnit (tous les tests)
make test FILES=tests/Api/Entity/MachineTest.php # Un test spécifique
make php-cs-fixer-allow-risky # Linter PHP (cs-fixer)
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate
# Frontend (dans Inventory_frontend/)
npm run dev # Dev server (port 3001)
npm run build # Build production
npm run lint:fix # ESLint fix
npx nuxi typecheck # TypeScript check (0 errors attendu)
# Release
./scripts/release.sh patch # Bump patch version (ou minor/major)
```
## Git Conventions
### Branches
- `master` — production
- `develop` — branche principale de dev (cible des PR)
- `feat/xxx`, `fix/xxx`, `refactor/xxx` — branches de travail
### Commit Message Format (enforced by hook)
```
<type>(<scope optionnel>) : <message>
```
**Espace obligatoire autour du `:`**. Types autorisés (minuscules) :
`build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`, `wip`
Exemples :
- `feat(auth) : add login page`
- `fix(machines) : prevent null crash on skeleton creation`
### Pre-commit Hook
1. php-cs-fixer sur les fichiers PHP stagés
2. PHPUnit — bloque le commit si tests échouent
### Submodule Workflow
Le frontend est un submodule git. Lors d'un commit frontend :
1. Commit dans `Inventory_frontend/` d'abord
2. Commit dans le repo principal pour mettre à jour le pointeur submodule
3. Push les deux repos
## Architecture Backend
### Entités Principales
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
### Patterns
- **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
- **ORM** : Attributs PHP 8 (`#[ORM\Column(...)]`, `#[Groups([...])]`)
- **Lifecycle** : `#[ORM\HasLifecycleCallbacks]` avec `PrePersist`/`PreUpdate` pour `createdAt`/`updatedAt`
- **Sécurité** : `security: "is_granted('ROLE_...')"` sur chaque opération API Platform
- **Audit** : Subscribers Doctrine `onFlush` capturent diff + snapshot complet
- **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence
### Custom Controllers (pas API Platform)
- `MachineStructureController``/api/machines/{id}/structure` (GET/PATCH) : hiérarchie complète machine avec normalisation JSON manuelle (pas Symfony Serializer). Source principale de données pour la page détail machine.
- `MachineCustomFieldsController``/api/machines/{id}/add-custom-fields` (POST) : initialise les CustomFieldValue manquants pour une machine.
- `CustomFieldValueController``/api/custom-fields/values/*` : CRUD + upsert pour les valeurs de champs perso.
### Custom Fields — Architecture
- **Composants/Pièces/Produits** : définitions dans le JSON `structure` du ModelType
- **Machines** : définitions = entités `CustomField` liées directement via `machineId` FK (pas de ModelType)
- Les deux partagent la même entité `CustomFieldValue` pour stocker les valeurs
### Rôles (hiérarchie)
```
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
```
### PostgreSQL — ATTENTION
- Les noms de colonnes sont **TOUJOURS EN MINUSCULES** dans PG
- Doctrine utilise camelCase (`typePieceId`) mais PG stocke `typepieceid`
- Le SQL brut doit utiliser les noms lowercase
- Tables de jointure many-to-many : colonnes `a` et `b` (ex: `_piececonstructeurs`)
## Architecture Frontend
### Patterns
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
- **Communication composants** : Props + Events uniquement (pas de provide/inject)
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`
- **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client
- **Auto-imports** : Nuxt auto-importe composants (`components/`) et composables (`composables/`)
### DaisyUI Classes
- Input : `input input-bordered input-sm md:input-md`
- Textarea : `textarea textarea-bordered textarea-sm md:textarea-md`
- Select : `select select-bordered select-sm md:select-md`
- Button : `btn btn-sm md:btn-md btn-primary`
## Règles Importantes
### CLAUDE.md — Maintenance obligatoire
- **Toujours consulter** ce fichier en début de conversation pour respecter les conventions
- **Mettre à jour** ce fichier quand une nouvelle convention, pattern ou décision architecturale est établie
- **Utiliser comme source de vérité** pour les commandes, patterns et règles du projet
### Toujours faire AVANT de modifier du code
1. **Lire le fichier** avant de l'éditer — ne jamais proposer de changements sur du code non lu
2. **Comprendre le pattern existant** — reproduire le style du fichier (noms, indentation, structure)
3. **Vérifier les deux repos** — un changement peut impacter backend ET frontend
### Après chaque modification
1. Backend PHP : `make php-cs-fixer-allow-risky`
2. Frontend : `npm run lint:fix` puis `npx nuxi typecheck` si fichiers TS modifiés
### Ne jamais faire
- Ajouter des features non demandées, du code mort, ou des abstractions prématurées
- Utiliser `provide/inject` — le codebase utilise Props + Events
- Utiliser JWT/tokens — l'auth est session-based
- Écrire du SQL avec des noms camelCase — PostgreSQL = lowercase
- Committer sans que l'utilisateur le demande explicitement
- Force push sans confirmation explicite
- Modifier la config git
### Submodule — Synchronisation
Quand les branches `master` et `develop` divergent sur l'un des deux repos, **toujours les synchroniser** :
- Main repo : `git checkout master && git merge develop && git push`
- Frontend : `git checkout develop && git merge master && git push` (ou l'inverse selon le cas)
## Tests
### Stack de test
- **PHPUnit 12** + **API Platform Test** (`ApiTestCase`)
- **DAMA DoctrineTestBundle** — wrappe chaque test dans une transaction avec rollback automatique (pas de TRUNCATE)
- Base de test : même PG, env `test`
### Commandes
Voir section "Key Commands". Commande additionnelle :
```bash
make test-setup # Créer/mettre à jour le schéma test
```
### Pattern de test
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`
## URLs Locales
- API Symfony : `http://localhost:8081/api`
- Nuxt dev : `http://localhost:3001`
- Adminer (PG) : `http://localhost:5050`
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)

312
DEPLOY.md Normal file
View File

@@ -0,0 +1,312 @@
# Inventory — Guide de Déploiement
Guide pour déployer l'application sur un serveur de production.
## Architecture de production
```
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 (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)
```
---
## Prérequis serveur
- **OS** : Ubuntu/Debian
- **PHP** : 8.4 avec extensions : pgsql, intl, zip, gd, mbstring, curl
- **Node.js** : 20+
- **Nginx**
- **PostgreSQL** : 16
- **Composer**
### Vérification des prérequis
```bash
php -v # PHP 8.4+
php -m | grep -E 'pgsql|intl|zip|gd|mbstring'
node -v # Node 20+
nginx -v
psql --version
composer --version
```
---
## Déploiement initial
### 1. Cloner le projet
```bash
cd /var/www
sudo git clone --recurse-submodules gitea@gitea.malio.fr:MALIO-DEV/Inventory.git Inventory
sudo chown -R malio:malio Inventory
cd Inventory
git checkout master
git submodule update --init --recursive
```
### 2. Créer la base de données
```bash
sudo -u postgres psql
CREATE DATABASE inventory OWNER ferme_user;
GRANT ALL PRIVILEGES ON DATABASE inventory TO ferme_user;
\q
```
Importer le dump :
```bash
# Copier le dump depuis le PC local
scp backup_v1.0.0_clean.sql malio@192.168.0.159:/tmp/
# Importer
psql -U ferme_user -h 127.0.0.1 -d inventory -f /tmp/backup_v1.0.0_clean.sql
```
### 3. Configurer le backend Symfony
```bash
cd /var/www/Inventory
# Installer les dépendances (sans les outils de dev)
composer install --no-dev --optimize-autoloader
# Créer le fichier de configuration locale
cat > .env.local << 'EOF'
APP_ENV=prod
APP_DEBUG=0
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$'
EOF
# Générer un secret aléatoire
sed -i "s/CHANGE_ME/$(openssl rand -hex 32)/" .env.local
# 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
```bash
cd /var/www/Inventory/Inventory_frontend
# Permissions
sudo chown -R malio:malio .
# Installer les dépendances
npm install
# Créer le fichier d'environnement
cat > .env << 'EOF'
NUXT_PUBLIC_API_BASE_URL=http://inventory.malio-dev.fr/api
EOF
# Générer le site statique
npx nuxi generate
```
### 5. Configurer Nginx
```bash
sudo nano /etc/nginx/sites-available/inventory
```
Contenu :
```nginx
server {
listen 80;
server_name inventory.malio-dev.fr;
# Gros fichiers (100MB max pour les uploads de documents)
client_max_body_size 100M;
client_body_timeout 300s;
send_timeout 300s;
access_log /var/log/nginx/inventory-access.log;
error_log /var/log/nginx/inventory-error.log;
# 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)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/Inventory/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/Inventory/public;
fastcgi_read_timeout 300s;
internal;
}
# Frontend statique — tout le reste
location / {
root /var/www/Inventory/Inventory_frontend/.output/public;
index index.html;
try_files $uri $uri/ /index.html; # SPA fallback
}
}
```
Activer le site :
```bash
sudo ln -s /etc/nginx/sites-available/inventory /etc/nginx/sites-enabled/
sudo nginx -t # Vérifier la syntaxe
sudo systemctl reload nginx
```
### 6. Vérifier
```bash
curl http://inventory.malio-dev.fr # Frontend
curl http://inventory.malio-dev.fr/api # API (doc Swagger)
```
---
## Mises à jour
### Mettre à jour l'application
```bash
cd /var/www/Inventory
# 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/
# Frontend
cd Inventory_frontend
npm install
npx nuxi generate
```
---
## Backup base de données
### Export (faire un backup)
```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 (restaurer un backup)
```bash
psql -U ferme_user -h 127.0.0.1 -d inventory -f backup_inventory_YYYYMMDD.sql
```
---
## 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
# Vider le cache Symfony
php /var/www/Inventory/bin/console cache:clear --env=prod
# Rebuild frontend
cd /var/www/Inventory/Inventory_frontend && npx nuxi generate
# Status des services
systemctl status php8.4-fpm
systemctl status nginx
systemctl status postgresql
# Redémarrer les services
sudo systemctl restart php8.4-fpm
sudo systemctl reload nginx
```

1
Inventory_frontend Submodule

Submodule Inventory_frontend added at b0124c11ba

330
README.md
View File

@@ -1,61 +1,305 @@
# Projet Ferme
# Inventory
## Installation du projet
### Windows
Pour windows, il faut installer le WSL2, Ubuntu, docker et nvm.
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows)
Application de gestion d'inventaire industriel pour **Malio**. Gestion complète du parc machines, des pièces, composants, produits, fournisseurs et documents associés, avec traçabilité et contrôle d'accès par rôles.
### Linux
Pour linux, il faut installer docker et nvm.
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux)
## C'est quoi ce projet ?
Inventory est une application web qui permet de gérer un parc de machines industrielles. Concrètement, elle permet de :
- **Cataloguer** les machines d'une usine, site par site
- **Décomposer** chaque machine en composants, pièces et produits (structure arborescente)
- **Suivre** les fournisseurs/constructeurs de chaque élément
- **Stocker** les documents techniques (PDF, images, fiches techniques)
- **Tracer** toutes les modifications (qui a changé quoi, quand) via un journal d'audit
- **Commenter** les fiches pour collaborer entre équipes
- **Gérer les accès** avec un système de rôles (admin, gestionnaire, lecteur)
L'application se compose de deux parties :
- Un **backend** (API REST) qui gère les données, la sécurité et la logique métier
- Un **frontend** (interface web) qui affiche les données et permet l'interaction utilisateur
## Stack technique
| Couche | Technologie | Version | Rôle |
|--------|-------------|---------|------|
| Backend | Symfony + API Platform | 8.0 / 4.2 | API REST, logique métier, sécurité |
| PHP | PHP | >= 8.4 | Langage backend |
| Base de données | PostgreSQL | 16 | Stockage des données |
| Frontend | Nuxt (SPA, SSR off) | 4 | Framework web (rendu côté client) |
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 | Composants d'interface |
| CSS | TailwindCSS + DaisyUI | 4 / 5 | Mise en page et composants visuels |
| Conteneurs | Docker Compose | | Environnement de développement |
## Prérequis
- **Docker** et **Docker Compose** (pour lancer le projet sans rien installer)
- **Node.js** >= 20 (via [nvm](https://github.com/nvm-sh/nvm))
- **make** (normalement déjà installé sur Linux/macOS)
### Guides d'installation de l'environnement
| OS | Documentation |
|----|---------------|
| Windows | [WSL2 + Ubuntu + Docker](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows) |
| Linux | [Docker + nvm](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux) |
## Installation rapide
### Installation du projet
Une fois les prérequis installés, il suffit de cloner le projet et de lancer les commandes suivantes
```bash
# 1. Cloner le projet avec le frontend (submodule)
git clone --recurse-submodules <url-du-repo>
cd Inventory
# 2. Démarrer les conteneurs Docker (PHP, PostgreSQL, Adminer)
make start
# 3. Installer les dépendances et builder le projet
make install
```
Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifier **POSTGRES_PORT** dans le fichier .env.docker.local, remplacer le par un port disponible.
### Configuration xdebug
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
* Name : ferme-docker
* Host : localhost
* Port : 8080
* Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `docker/.env.docker.local`.
Pour que xdebug fonctionne sur windows, il faut modifier la variable **XDEBUG_CLIENT_HOST** par votre ip local
### Que fait `make install` ?
## Utilisation du projet
### Backend
L'api est disponible sur http://localhost:8080/api
Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local**
Vous pouvez modifier le port si nécessaire.
1. Installe les dépendances PHP (via Composer)
2. Installe les dépendances Node.js (via npm)
3. Build le frontend Nuxt
La bdd est déja pré-configuré dans PhpStorm, il suffit de rentrer les infos du .env.docker.local pour se connecter.
C'est un bdd local dans le docker.
### Frontend
Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev
```bash
make dev-nuxt
```
Le front sera accessible sur http://localhost:3000
### Premier lancement
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 | Description |
|---------|-----|-------------|
| API Symfony | http://localhost:8081/api | Documentation interactive de l'API (Swagger) |
| Frontend Nuxt | http://localhost:3001 | L'application web |
| Adminer (BDD) | http://localhost:5050 | Interface web pour explorer la base de données |
| PostgreSQL | `localhost:5433` | Connexion directe (user: root, pass: root, db: inventory) |
## Commandes utiles
Pour restart le container
### Docker
| Commande | Description |
|----------|-------------|
| `make start` | Démarrer les conteneurs |
| `make stop` | Arrêter les conteneurs |
| `make restart` | Redémarrer les conteneurs |
| `make shell` | Ouvrir un terminal dans le conteneur PHP (pour lancer des commandes Symfony) |
| `make reset` | Reset complet (supprime les volumes, réinstalle tout) |
### Backend
| Commande | Description |
|----------|-------------|
| `make test` | Lancer les tests PHPUnit |
| `make test FILES=tests/Api/Entity/MachineTest.php` | Lancer un test spécifique |
| `make test-setup` | Créer/mettre à jour la base de test |
| `make php-cs-fixer-allow-risky` | Formatter le code PHP (indentation, espaces, etc.) |
| `make cache-clear` | Vider le cache Symfony (à faire si tu as des erreurs bizarres) |
| `make db-reset` | Reset de la BDD (supprime toutes les données) |
| `make fixtures-load` | Charger les données de test |
| `make fixtures-dump` | Sauvegarder la BDD actuelle dans fixtures/data.sql |
### Frontend
| Commande | Description |
|----------|-------------|
| `make dev-nuxt` | Lancer le serveur de dev Nuxt (avec rechargement automatique) |
| `make build-nuxtJS` | Builder le frontend pour la production |
### Release
```bash
make restart
./scripts/release.sh patch # Bump patch (ou minor / major)
```
Pour lancer les TU
```bash
make test
Synchronise automatiquement la version dans `VERSION`, `api_platform.yaml` et `nuxt.config.ts`, crée le tag git et pousse les deux repos.
## Architecture globale
### Comment ça marche ?
```
Pour accéder au container et lance des commandes
```bash
make shell
┌──────────────────┐ HTTP (JSON) ┌──────────────────┐ SQL ┌────────────┐
│ Frontend │ ◄─────────────────► │ Backend │ ◄──────────► │ PostgreSQL │
│ (Nuxt/Vue) │ cookies session │ (Symfony/API) │ │ (BDD) │
│ localhost:3001 │ │ localhost:8081 │ │ port 5433 │
└──────────────────┘ └──────────────────┘ └────────────┘
│ │
Interface web API REST + logique
pour l'utilisateur métier + sécurité
```
Pour clear le cache Symfony
```bash
make cache-clear
1. L'utilisateur ouvre le navigateur sur `localhost:3001`
2. Le frontend (Vue/Nuxt) affiche l'interface
3. Quand l'utilisateur fait une action (créer une machine, etc.), le frontend envoie une requête HTTP à l'API backend
4. Le backend valide la requête, vérifie les permissions, exécute la logique métier
5. Le backend lit/écrit dans PostgreSQL et renvoie une réponse JSON
6. Le frontend met à jour l'interface avec les nouvelles données
### Structure du projet
```
Inventory/ # Backend Symfony (repo principal)
├── src/
│ ├── Entity/ # Les "modèles" de données (Machine, Piece, etc.)
│ ├── Controller/ # Les endpoints API personnalisés
│ ├── EventSubscriber/ # Logique déclenchée automatiquement (audit, etc.)
│ ├── Command/ # Commandes CLI (lancer via php bin/console)
│ ├── Service/ # Services métier (stockage fichiers, PDF, etc.)
│ ├── State/ # Processeurs API Platform (hashage mot de passe, upload)
│ ├── Repository/ # Requêtes BDD personnalisées
│ ├── Security/ # Authentification par session
│ └── Serializer/ # Conversion entité ↔ JSON personnalisée
├── config/ # Configuration Symfony (routes, sécurité, etc.)
├── migrations/ # Scripts de modification de la BDD
├── fixtures/ # Données de test (SQL)
├── tests/ # Tests automatisés (PHPUnit)
├── scripts/ # Utilitaires (release, migration, normalisation)
├── docker/ # Dockerfile + config Docker
├── makefile # Commandes de dev raccourcies
├── VERSION # Version courante (ex: 1.8.1)
└── Inventory_frontend/ # Submodule git (frontend, repo séparé)
├── app/pages/ # Les pages de l'app (1 fichier = 1 route URL)
├── app/components/ # Composants Vue réutilisables
├── app/composables/ # Logique métier partagée (appels API, états)
├── app/shared/ # Types TypeScript, utilitaires, validation
├── app/middleware/ # Vérification de session automatique
└── app/services/ # Couche service (wrappers API)
```
### Entités principales (les "tables" de la BDD)
| Entité | Description | Exemple |
|--------|-------------|---------|
| `Machine` | Machines du parc industriel | "CNC Mazak 01" |
| `Composant` | Composants fonctionnels d'une machine | "Broche principale" |
| `Piece` | Pièces détachées/de rechange | "Roulement SKF 6205" |
| `Product` | Produits fournisseur (consommables, outillage) | "Huile de coupe X" |
| `Site` | Sites physiques / usines | "Usine de Strasbourg" |
| `Constructeur` | Fournisseurs / fabricants | "SKF", "Mazak" |
| `ModelType` | Catégories avec squelettes de structure | "Type: Moteur électrique" |
| `CustomField` / `CustomFieldValue` | Champs personnalisés (dynamiques) | "Tension : 220V" |
| `Document` | Documents uploadés (PDF, images, etc.) | "Fiche technique CNC.pdf" |
| `AuditLog` | Journal d'audit (historique des modifications) | "Machine X modifiée par Jean" |
| `Comment` | Commentaires / tickets sur les fiches | "Vérifier le roulement" |
| `Profile` | Comptes utilisateurs avec rôles | "admin@malio.fr (ADMIN)" |
### Structure hiérarchique d'une machine
Une machine peut contenir une arborescence de composants, pièces et produits :
```
Machine "CNC Mazak 01"
├── Composant "Broche principale"
│ ├── Pièce "Roulement avant"
│ │ └── Produit "Graisse SKF LGMT2"
│ └── Pièce "Joint d'étanchéité"
├── Composant "Système hydraulique"
│ ├── Pièce "Pompe HP"
│ └── Produit "Huile hydraulique ISO 46"
└── Produit "Filtre à air cabine"
```
### Rôles et permissions
```
ROLE_ADMIN → Tout faire + gérer les utilisateurs
↓ hérite de
ROLE_GESTIONNAIRE → Créer, modifier, supprimer les données
↓ hérite de
ROLE_VIEWER → Lecture seule sur toutes les données
↓ hérite de
ROLE_USER → Accès de base (rôle minimum)
```
### Authentification
Authentification par **session (cookies)**, pas de JWT. Le profil actif est stocké en session côté serveur. Concrètement :
1. L'utilisateur choisit son profil sur la page de login
2. Il entre son mot de passe
3. Le backend crée une session et envoie un cookie au navigateur
4. À chaque requête suivante, le navigateur envoie automatiquement ce cookie
5. Le backend vérifie le cookie et identifie l'utilisateur
### Base de données — Points importants
PostgreSQL 16 avec les particularités suivantes :
- **IDs** : chaînes CUID (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
- **Noms de colonnes** : toujours en **minuscules** dans PostgreSQL (Doctrine map `typePieceId``typepieceid`)
- **Audit** : les subscribers Doctrine `onFlush` capturent le diff + snapshot complet de chaque modification
- **Migrations** : SQL brut avec `IF NOT EXISTS` / `IF EXISTS` pour l'idempotence
## Services Docker
| Service | Image | Port | Rôle |
|---------|-------|------|------|
| `web` | PHP 8.4 + Apache + Node | 8081, 3001 | API Symfony + Nuxt dev |
| `db` | PostgreSQL 16 Alpine | 5433 | Base de données |
| `adminer` | Adminer | 5050 | Interface web pour explorer la BDD |
## Xdebug
Configuration PhpStorm / VSCode :
- **Serveur** : `inventory-docker`
- **Host** : `localhost`
- **Port** : `8081`
- **Path mapping** : racine du projet → `/var/www/html`
> Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `docker/.env.docker.local` avec votre IP locale.
## Git
### Branches
- `master` : production
- `develop` : branche principale de dev (cible des PR)
- `feat/xxx`, `fix/xxx`, `refactor/xxx` : branches de travail
### Convention de commit (enforced par un hook)
```
<type>(<scope>) : <message>
```
**Espace obligatoire autour du `:`**. Types autorisés (minuscules) :
`feat`, `fix`, `perf`, `refactor`, `chore`, `docs`, `test`, `style`, `build`, `ci`, `revert`, `wip`
Exemples :
```
feat(machines) : add clone functionality
fix(documents) : prevent duplicate upload
refactor(audit) : merge history controllers
chore(deps) : update composer packages
```
### Pre-commit hook
Le hook `pre-commit` s'exécute automatiquement avant chaque commit :
1. **php-cs-fixer** — Formate automatiquement les fichiers PHP modifiés
2. **PHPUnit** — Lance les tests. Si un test échoue, le commit est bloqué
### Submodule frontend
Le frontend est un **submodule git** dans `Inventory_frontend/` (c'est un repo git séparé, inclus dans le repo principal). Workflow de commit :
1. Commiter dans `Inventory_frontend/` d'abord
2. Commiter dans le repo principal pour mettre à jour le pointeur du submodule
3. Pousser les deux repos
## Documentation détaillée
- **[docs/BACKEND.md](docs/BACKEND.md)** : guide complet du backend (entités, controllers, API, audit, tests)
- **[docs/FRONTEND.md](docs/FRONTEND.md)** : guide complet du frontend (pages, composables, composants, patterns)
- **[DEPLOY.md](DEPLOY.md)** : guide de déploiement serveur (Nginx, PHP-FPM, PostgreSQL)
- **[RELEASE.md](RELEASE.md)** : processus de release et versioning
- **[CHANGELOG.md](CHANGELOG.md)** : historique des versions
- **[Frontend README](Inventory_frontend/README.md)** : documentation du frontend Nuxt

122
RELEASE.md Normal file
View File

@@ -0,0 +1,122 @@
# Guide de Release
## C'est quoi une release ?
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**.
## 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.
## Créer une release
### Prérequis
- 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
```bash
# Afficher l'aide et la version actuelle
./scripts/release.sh
# Bump patch : 1.8.1 → 1.8.2
./scripts/release.sh patch
# Bump minor : 1.8.1 → 1.9.0
./scripts/release.sh minor
# Bump major : 1.8.1 → 2.0.0
./scripts/release.sh major
# Version spécifique
./scripts/release.sh 2.0.0
```
### 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
```
### 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. 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 (à copier dans Gitea) :
```markdown
## Nouveautés
- Feature A
- Feature B
## Corrections
- Fix du bug X
- Fix du bug Y
## Changements
- Refactoring de Z
- Mise à jour des dépendances
## Migration requise
\`\`\`bash
docker compose exec web php bin/console doctrine:migrations:migrate
\`\`\`
```
## Déploiement après une release
Voir [DEPLOY.md](DEPLOY.md) pour les instructions de mise à jour en production.
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 doctrine:migrations:migrate --no-interaction
php bin/console cache:clear --env=prod
cd Inventory_frontend && npm install && npx nuxi generate
```

1
VERSION Normal file
View File

@@ -0,0 +1 @@
1.9.0

View File

@@ -11,7 +11,7 @@ fi
# Types autorisés (MINUSCULES uniquement)
# Optionnel: scope => feat(auth) : ...
REGEX='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9._-]+\))?\ :\ .+'
REGEX='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test|wip)(\([a-z0-9._-]+\))?\ :\ .+'
if [[ ! "$FIRST_LINE" =~ $REGEX ]]; then
echo "❌ Message de commit invalide."

View File

@@ -12,6 +12,7 @@
"doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6",
"lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3",
@@ -27,8 +28,10 @@
"symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*",
"symfony/twig-bundle": "8.0.*",
"symfony/uid": "8.0.*",
"symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*"
"symfony/yaml": "8.0.*",
"vich/uploader-bundle": "^2.9"
},
"config": {
"allow-plugins": {
@@ -83,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.*"
}
}

795
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": "bab4560dec1d36eec0b0aa2284bd8559",
"content-hash": "97c89001351c3dcf060e2b9b5f37a8a6",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -2361,6 +2361,259 @@
},
"time": "2025-10-26T09:35:14+00:00"
},
{
"name": "jms/metadata",
"version": "2.9.0",
"source": {
"type": "git",
"url": "https://github.com/schmittjoh/metadata.git",
"reference": "554319d2e5f0c5d8ccaeffe755eac924e14da330"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/schmittjoh/metadata/zipball/554319d2e5f0c5d8ccaeffe755eac924e14da330",
"reference": "554319d2e5f0c5d8ccaeffe755eac924e14da330",
"shasum": ""
},
"require": {
"php": "^7.2|^8.0"
},
"require-dev": {
"doctrine/cache": "^1.0|^2.0",
"doctrine/coding-standard": "^8.0",
"mikey179/vfsstream": "^1.6.7",
"phpunit/phpunit": "^8.5.42|^9.6.23",
"psr/container": "^1.0|^2.0",
"symfony/cache": "^3.1|^4.0|^5.0|^6.0|^7.0|^8.0",
"symfony/dependency-injection": "^3.1|^4.0|^5.0|^6.0|^7.0|^8.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"Metadata\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Johannes M. Schmitt",
"email": "schmittjoh@gmail.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "Class/method/property metadata management in PHP",
"keywords": [
"annotations",
"metadata",
"xml",
"yaml"
],
"support": {
"issues": "https://github.com/schmittjoh/metadata/issues",
"source": "https://github.com/schmittjoh/metadata/tree/2.9.0"
},
"time": "2025-11-30T20:12:26+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
"source": {
"type": "git",
"url": "https://github.com/lcobucci/jwt.git",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-sodium": "*",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/clock": "^1.0"
},
"require-dev": {
"infection/infection": "^0.29",
"lcobucci/clock": "^3.2",
"lcobucci/coding-standard": "^11.0",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.10.7",
"phpstan/phpstan-deprecation-rules": "^1.1.3",
"phpstan/phpstan-phpunit": "^1.3.10",
"phpstan/phpstan-strict-rules": "^1.5.0",
"phpunit/phpunit": "^11.1"
},
"suggest": {
"lcobucci/clock": ">= 3.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Lcobucci\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Luís Cobucci",
"email": "lcobucci@gmail.com",
"role": "Developer"
}
],
"description": "A simple library to work with JSON Web Token and JSON Web Signature",
"keywords": [
"JWS",
"jwt"
],
"support": {
"issues": "https://github.com/lcobucci/jwt/issues",
"source": "https://github.com/lcobucci/jwt/tree/5.6.0"
},
"funding": [
{
"url": "https://github.com/lcobucci",
"type": "github"
},
{
"url": "https://www.patreon.com/lcobucci",
"type": "patreon"
}
],
"time": "2025-10-17T11:30:53+00:00"
},
{
"name": "lexik/jwt-authentication-bundle",
"version": "v3.2.0",
"source": {
"type": "git",
"url": "https://github.com/lexik/LexikJWTAuthenticationBundle.git",
"reference": "60df75dc70ee6f597929cb2f0812adda591dfa4b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lexik/LexikJWTAuthenticationBundle/zipball/60df75dc70ee6f597929cb2f0812adda591dfa4b",
"reference": "60df75dc70ee6f597929cb2f0812adda591dfa4b",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"lcobucci/jwt": "^5.0",
"php": ">=8.2",
"symfony/clock": "^6.4|^7.0|^8.0",
"symfony/config": "^6.4|^7.0|^8.0",
"symfony/dependency-injection": "^6.4|^7.0|^8.0",
"symfony/deprecation-contracts": "^2.4|^3.0",
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/http-kernel": "^6.4|^7.0|^8.0",
"symfony/property-access": "^6.4|^7.0|^8.0",
"symfony/security-bundle": "^6.4|^7.0|^8.0",
"symfony/translation-contracts": "^1.0|^2.0|^3.0"
},
"require-dev": {
"api-platform/core": "^3.0|^4.0",
"rector/rector": "^1.2",
"symfony/browser-kit": "^6.4|^7.0|^8.0",
"symfony/console": "^6.4|^7.0|^8.0",
"symfony/dom-crawler": "^6.4|^7.0|^8.0",
"symfony/filesystem": "^6.4|^7.0|^8.0",
"symfony/framework-bundle": "^6.4|^7.0|^8.0",
"symfony/phpunit-bridge": "^6.4|^7.0|^8.0",
"symfony/var-dumper": "^6.4|^7.0|^8.0",
"symfony/yaml": "^6.4|^7.0|^8.0"
},
"suggest": {
"gesdinet/jwt-refresh-token-bundle": "Implements a refresh token system over Json Web Tokens in Symfony",
"spomky-labs/lexik-jose-bridge": "Provides a JWT Token encoder with encryption support"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Lexik\\Bundle\\JWTAuthenticationBundle\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jeremy Barthe",
"email": "j.barthe@lexik.fr",
"homepage": "https://github.com/jeremyb"
},
{
"name": "Nicolas Cabot",
"email": "n.cabot@lexik.fr",
"homepage": "https://github.com/slashfan"
},
{
"name": "Cedric Girard",
"email": "c.girard@lexik.fr",
"homepage": "https://github.com/cedric-g"
},
{
"name": "Dev Lexik",
"email": "dev@lexik.fr",
"homepage": "https://github.com/lexik"
},
{
"name": "Robin Chalas",
"email": "robin.chalas@gmail.com",
"homepage": "https://github.com/chalasr"
},
{
"name": "Lexik Community",
"homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle/graphs/contributors"
}
],
"description": "This bundle provides JWT authentication for your Symfony REST API",
"homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle",
"keywords": [
"Authentication",
"JWS",
"api",
"bundle",
"jwt",
"rest",
"symfony"
],
"support": {
"issues": "https://github.com/lexik/LexikJWTAuthenticationBundle/issues",
"source": "https://github.com/lexik/LexikJWTAuthenticationBundle/tree/v3.2.0"
},
"funding": [
{
"url": "https://github.com/chalasr",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/lexik/jwt-authentication-bundle",
"type": "tidelift"
}
],
"time": "2025-12-20T17:47:00+00:00"
},
{
"name": "nelmio/cors-bundle",
"version": "2.6.0",
@@ -4613,6 +4866,92 @@
],
"time": "2025-12-31T09:29:34+00:00"
},
{
"name": "symfony/mime",
"version": "v8.0.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
"reference": "7576ce3b2b4d3a2a7fe7020a07a392065d6ffd40"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/7576ce3b2b4d3a2a7fe7020a07a392065d6ffd40",
"reference": "7576ce3b2b4d3a2a7fe7020a07a392065d6ffd40",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/polyfill-intl-idn": "^1.10",
"symfony/polyfill-mbstring": "^1.0"
},
"conflict": {
"egulias/email-validator": "~3.0.0",
"phpdocumentor/reflection-docblock": "<3.2.2",
"phpdocumentor/type-resolver": "<1.4.0"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3.1|^4",
"league/html-to-markdown": "^5.0",
"phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0",
"symfony/dependency-injection": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/property-access": "^7.4|^8.0",
"symfony/property-info": "^7.4|^8.0",
"symfony/serializer": "^7.4|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Mime\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Allows manipulating MIME messages",
"homepage": "https://symfony.com",
"keywords": [
"mime",
"mime-type"
],
"support": {
"source": "https://github.com/symfony/mime/tree/v8.0.0"
},
"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": "2025-11-16T10:17:21+00:00"
},
{
"name": "symfony/password-hasher",
"version": "v8.0.0",
@@ -4768,6 +5107,93 @@
],
"time": "2025-06-27T09:58:17+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"symfony/polyfill-intl-normalizer": "^1.10"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Idn\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Laurent Bassin",
"email": "laurent@bassin.info"
},
{
"name": "Trevor Rowbotham",
"email": "trevor.rowbotham@pm.me"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"idn",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
},
"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": "2024-09-10T14:38:51+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.33.0",
@@ -7040,6 +7466,114 @@
],
"time": "2025-12-14T11:28:47+00:00"
},
{
"name": "vich/uploader-bundle",
"version": "v2.9.1",
"source": {
"type": "git",
"url": "https://github.com/dustin10/VichUploaderBundle.git",
"reference": "945939a04a33c0b78c5fbb7ead31533d85112df5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dustin10/VichUploaderBundle/zipball/945939a04a33c0b78c5fbb7ead31533d85112df5",
"reference": "945939a04a33c0b78c5fbb7ead31533d85112df5",
"shasum": ""
},
"require": {
"doctrine/persistence": "^3.0 || ^4.0",
"ext-simplexml": "*",
"jms/metadata": "^2.4",
"php": "^8.1",
"symfony/config": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/dependency-injection": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/event-dispatcher-contracts": "^3.1",
"symfony/http-foundation": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/http-kernel": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/mime": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/property-access": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/string": "^5.4 || ^6.0 || ^7.0 || ^8.0"
},
"conflict": {
"doctrine/annotations": "<1.12",
"league/flysystem": "<2.0"
},
"require-dev": {
"dg/bypass-finals": "^1.9",
"doctrine/common": "^3.0",
"doctrine/doctrine-bundle": "^2.7 || ^3.0",
"doctrine/mongodb-odm": "^2.4",
"doctrine/orm": "^2.13 || ^3.0",
"ext-sqlite3": "*",
"knplabs/knp-gaufrette-bundle": "dev-master",
"league/flysystem-bundle": "^2.4 || ^3.0",
"league/flysystem-memory": "^2.0 || ^3.0",
"matthiasnoback/symfony-dependency-injection-test": "^5.1 || ^6.0",
"mikey179/vfsstream": "^1.6.11",
"phpunit/phpunit": "^10.5 || ^11.5 || ^12.2",
"symfony/asset": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/browser-kit": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/doctrine-bridge": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/dom-crawler": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/form": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/phpunit-bridge": "^7.3",
"symfony/security-csrf": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/translation": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/twig-bridge": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/twig-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/validator": "^5.4.22 || ^6.0 || ^7.0 || ^8.0",
"symfony/var-dumper": "^5.4 || ^6.0 || ^7.0 || ^8.0",
"symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0"
},
"suggest": {
"doctrine/doctrine-bundle": "For integration with Doctrine",
"doctrine/mongodb-odm-bundle": "For integration with Doctrine ODM",
"doctrine/orm": "For integration with Doctrine ORM",
"doctrine/phpcr-odm": "For integration with Doctrine PHPCR",
"knplabs/knp-gaufrette-bundle": "For integration with Gaufrette",
"league/flysystem-bundle": "For integration with Flysystem",
"liip/imagine-bundle": "To generate image thumbnails",
"oneup/flysystem-bundle": "For integration with Flysystem",
"symfony/asset": "To generate better links",
"symfony/form": "To handle uploads in forms",
"symfony/yaml": "To use YAML mapping"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-master": "2.x-dev"
}
},
"autoload": {
"psr-4": {
"Vich\\UploaderBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Dustin Dobervich",
"email": "ddobervich@gmail.com"
}
],
"description": "Ease file uploads attached to entities",
"homepage": "https://github.com/dustin10/VichUploaderBundle",
"keywords": [
"file uploads",
"upload"
],
"support": {
"issues": "https://github.com/dustin10/VichUploaderBundle/issues",
"source": "https://github.com/dustin10/VichUploaderBundle/tree/v2.9.1"
},
"time": "2025-12-10T08:23:38+00:00"
},
{
"name": "webmozart/assert",
"version": "2.0.0",
@@ -7446,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",
@@ -9810,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": {
@@ -9858,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": [
{
@@ -9878,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",
@@ -10019,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",
@@ -10208,7 +10985,7 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
@@ -10216,6 +10993,6 @@
"ext-ctype": "*",
"ext-iconv": "*"
},
"platform-dev": [],
"platform-dev": {},
"plugin-api-version": "2.9.0"
}

View File

@@ -1,11 +1,25 @@
<?php
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;
use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle;
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
FrameworkBundle::class => ['all' => true],
TwigBundle::class => ['all' => true],
SecurityBundle::class => ['all' => true],
DoctrineBundle::class => ['all' => true],
DoctrineMigrationsBundle::class => ['all' => true],
NelmioCorsBundle::class => ['all' => true],
ApiPlatformBundle::class => ['all' => true],
LexikJWTAuthenticationBundle::class => ['all' => true],
DAMADoctrineTestBundle::class => ['test' => true],
];

View File

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

View File

@@ -1,6 +1,9 @@
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
mapping_types:
modelcategory: string
_text: string
# IMPORTANT: You MUST configure your server version,
# either here or in the DATABASE_URL env var (see .env file)
@@ -9,7 +12,8 @@ doctrine:
profiling_collect_backtrace: '%kernel.debug%'
orm:
validate_xml_mapping: true
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
naming_strategy: doctrine.orm.naming_strategy.default
quote_strategy: doctrine.orm.quote_strategy.default
identity_generation_preferences:
Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity
auto_mapping: true

View File

@@ -0,0 +1,4 @@
lexik_jwt_authentication:
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
pass_phrase: '%env(JWT_PASSPHRASE)%'

View File

@@ -4,7 +4,8 @@ nelmio_cors:
allow_origin: ['%env(CORS_ALLOW_ORIGIN)%']
allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE']
allow_headers: ['Content-Type', 'Authorization']
allow_credentials: true
expose_headers: ['Link']
max_age: 3600
paths:
'^/': null
'^/api/': ~

View File

@@ -1,31 +1,58 @@
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'
App\Entity\Profile:
algorithm: auto
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
providers:
users_in_memory: { memory: null }
app_user_provider:
entity:
class: App\Entity\Profile
property: email
firewalls:
dev:
# Ensure dev tools and static assets are always allowed
pattern: ^/(_profiler|_wdt|assets|build)/
security: false
session_public:
pattern: ^/api/session/profiles?$
security: false
api:
pattern: ^/api
stateless: false
custom_authenticators:
- App\Security\SessionProfileAuthenticator
main:
lazy: true
provider: users_in_memory
provider: app_user_provider
# Activate different ways to authenticate:
# https://symfony.com/doc/current/security.html#the-firewall
# https://symfony.com/doc/current/security/impersonating_user.html
# switch_user: true
role_hierarchy:
ROLE_ADMIN: ROLE_GESTIONNAIRE
ROLE_GESTIONNAIRE: ROLE_VIEWER
ROLE_VIEWER: ROLE_USER
# Note: Only the *first* matching rule is applied
access_control:
# - { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }
- { path: ^/api/session/profile$, roles: PUBLIC_ACCESS }
- { path: ^/api/session/profiles, roles: PUBLIC_ACCESS, methods: [GET] }
- { path: ^/api/admin, roles: ROLE_ADMIN }
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
- { path: ^/docs, roles: PUBLIC_ACCESS }
- { path: ^/contexts, roles: PUBLIC_ACCESS }
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
- { path: ^/api, roles: ROLE_VIEWER }
when@test:
security:

View File

@@ -768,6 +768,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* property?: scalar|null|Param, // Default: null
* manager_name?: scalar|null|Param, // Default: null
* },
* lexik_jwt?: array{
* class?: scalar|null|Param, // Default: "Lexik\\Bundle\\JWTAuthenticationBundle\\Security\\User\\JWTUser"
* },
* }>,
* firewalls: array<string, array{ // Default: []
* pattern?: scalar|null|Param,
@@ -826,6 +829,10 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* provider?: scalar|null|Param,
* user?: scalar|null|Param, // Default: "REMOTE_USER"
* },
* jwt?: array{
* provider?: scalar|null|Param, // Default: null
* authenticator?: scalar|null|Param, // Default: "lexik_jwt_authentication.security.jwt_authenticator"
* },
* login_link?: array{
* check_route: scalar|null|Param, // Route that will validate the login link - e.g. "app_login_link_verify".
* check_post_only?: scalar|null|Param, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false
@@ -1514,6 +1521,91 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* ...<mixed>
* },
* }
* @psalm-type LexikJwtAuthenticationConfig = array{
* public_key?: scalar|null|Param, // The key used to sign tokens (useless for HMAC). If not set, the key will be automatically computed from the secret key. // Default: null
* additional_public_keys?: list<scalar|null|Param>,
* secret_key?: scalar|null|Param, // The key used to sign tokens. It can be a raw secret (for HMAC), a raw RSA/ECDSA key or the path to a file itself being plaintext or PEM. // Default: null
* pass_phrase?: scalar|null|Param, // The key passphrase (useless for HMAC) // Default: ""
* token_ttl?: scalar|null|Param, // Default: 3600
* allow_no_expiration?: bool|Param, // Allow tokens without "exp" claim (i.e. indefinitely valid, no lifetime) to be considered valid. Caution: usage of this should be rare. // Default: false
* clock_skew?: scalar|null|Param, // Default: 0
* encoder?: array{
* service?: scalar|null|Param, // Default: "lexik_jwt_authentication.encoder.lcobucci"
* signature_algorithm?: scalar|null|Param, // Default: "RS256"
* },
* user_id_claim?: scalar|null|Param, // Default: "username"
* token_extractors?: array{
* authorization_header?: bool|array{
* enabled?: bool|Param, // Default: true
* prefix?: scalar|null|Param, // Default: "Bearer"
* name?: scalar|null|Param, // Default: "Authorization"
* },
* cookie?: bool|array{
* enabled?: bool|Param, // Default: false
* name?: scalar|null|Param, // Default: "BEARER"
* },
* query_parameter?: bool|array{
* enabled?: bool|Param, // Default: false
* name?: scalar|null|Param, // Default: "bearer"
* },
* split_cookie?: bool|array{
* enabled?: bool|Param, // Default: false
* cookies?: list<scalar|null|Param>,
* },
* },
* remove_token_from_body_when_cookies_used?: scalar|null|Param, // Default: true
* set_cookies?: array<string, array{ // Default: []
* lifetime?: scalar|null|Param, // The cookie lifetime. If null, the "token_ttl" option value will be used // Default: null
* samesite?: "none"|"lax"|"strict"|Param, // Default: "lax"
* path?: scalar|null|Param, // Default: "/"
* domain?: scalar|null|Param, // Default: null
* secure?: scalar|null|Param, // Default: true
* httpOnly?: scalar|null|Param, // Default: true
* partitioned?: scalar|null|Param, // Default: false
* split?: list<scalar|null|Param>,
* }>,
* api_platform?: bool|array{ // API Platform compatibility: add check_path in OpenAPI documentation.
* enabled?: bool|Param, // Default: false
* check_path?: scalar|null|Param, // The login check path to add in OpenAPI. // Default: null
* username_path?: scalar|null|Param, // The path to the username in the JSON body. // Default: null
* password_path?: scalar|null|Param, // The path to the password in the JSON body. // Default: null
* },
* access_token_issuance?: bool|array{
* enabled?: bool|Param, // Default: false
* signature?: array{
* algorithm: scalar|null|Param, // The algorithm use to sign the access tokens.
* key: scalar|null|Param, // The signature key. It shall be JWK encoded.
* },
* encryption?: bool|array{
* enabled?: bool|Param, // Default: false
* key_encryption_algorithm: scalar|null|Param, // The key encryption algorithm is used to encrypt the token.
* content_encryption_algorithm: scalar|null|Param, // The key encryption algorithm is used to encrypt the token.
* key: scalar|null|Param, // The encryption key. It shall be JWK encoded.
* },
* },
* access_token_verification?: bool|array{
* enabled?: bool|Param, // Default: false
* signature?: array{
* header_checkers?: list<scalar|null|Param>,
* claim_checkers?: list<scalar|null|Param>,
* mandatory_claims?: list<scalar|null|Param>,
* allowed_algorithms?: list<scalar|null|Param>,
* keyset: scalar|null|Param, // The signature keyset. It shall be JWKSet encoded.
* },
* encryption?: bool|array{
* enabled?: bool|Param, // Default: false
* continue_on_decryption_failure?: bool|Param, // If enable, non-encrypted tokens or tokens that failed during decryption or verification processes are accepted. // Default: false
* header_checkers?: list<scalar|null|Param>,
* allowed_key_encryption_algorithms?: list<scalar|null|Param>,
* allowed_content_encryption_algorithms?: list<scalar|null|Param>,
* keyset: scalar|null|Param, // The encryption keyset. It shall be JWKSet encoded.
* },
* },
* blocklist_token?: bool|array{
* enabled?: bool|Param, // Default: false
* cache?: scalar|null|Param, // Storage to track blocked tokens // Default: "cache.app"
* },
* }
* @psalm-type ConfigType = array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1525,6 +1617,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* doctrine_migrations?: DoctrineMigrationsConfig,
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* "when@dev"?: array{
* imports?: ImportsConfig,
* parameters?: ParametersConfig,
@@ -1536,6 +1629,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* doctrine_migrations?: DoctrineMigrationsConfig,
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* },
* "when@prod"?: array{
* imports?: ImportsConfig,
@@ -1548,6 +1642,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* doctrine_migrations?: DoctrineMigrationsConfig,
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* },
* "when@test"?: array{
* imports?: ImportsConfig,
@@ -1560,6 +1655,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* doctrine_migrations?: DoctrineMigrationsConfig,
* nelmio_cors?: NelmioCorsConfig,
* api_platform?: ApiPlatformConfig,
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
* },
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
* imports?: ImportsConfig,

View File

@@ -7,5 +7,8 @@
# To list all registered routes, run the following command:
# bin/console debug:router
api_login_check:
path: /api/login_check
controllers:
resource: routing.controllers

View File

@@ -1,4 +1,4 @@
api_platform:
resource: .
type: api_platform
prefix: /
prefix: /api

View File

@@ -0,0 +1,5 @@
controllers:
resource:
path: ../../src/Controller/
namespace: App\Controller
type: attribute

View File

@@ -21,3 +21,20 @@ services:
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones
App\EventSubscriber\ProductAuditSubscriber:
tags:
- { name: doctrine.event_subscriber }
App\EventSubscriber\PieceAuditSubscriber:
tags:
- { name: doctrine.event_subscriber }
App\EventSubscriber\ComposantAuditSubscriber:
tags:
- { name: doctrine.event_subscriber }
App\OpenApi\OpenApiDecorator:
decorates: 'api_platform.openapi.factory'
arguments:
$decorated: '@.inner'

61
create_test_user.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/vendor/autoload.php';
use Symfony\Component\PasswordHasher\Hasher\PasswordHasherFactory;
// Hash the password
$factory = new PasswordHasherFactory([
'common' => ['algorithm' => 'bcrypt'],
'memory-hard' => ['algorithm' => 'argon2i'],
]);
$passwordHasher = $factory->getPasswordHasher('common');
$hashedPassword = $passwordHasher->hash('admin123');
// Connect to database
$pdo = new PDO(
'pgsql:host=db;port=5432;dbname=inventory',
'root',
'root'
);
// Check if table exists
$tableExists = $pdo->query("SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'profiles')")->fetchColumn();
if ($tableExists) {
echo "Table 'profiles' exists.\n";
// Check if user exists
$userExists = $pdo->prepare('SELECT COUNT(*) FROM profiles WHERE email = ?');
$userExists->execute(['admin@admin.com']);
if ($userExists->fetchColumn() > 0) {
echo "User admin@admin.com already exists. Updating password...\n";
$stmt = $pdo->prepare('UPDATE profiles SET password = ? WHERE email = ?');
$stmt->execute([$hashedPassword, 'admin@admin.com']);
echo "Password updated!\n";
} else {
echo "Creating user admin@admin.com...\n";
$stmt = $pdo->prepare('INSERT INTO profiles (id, email, first_name, last_name, is_active, roles, password, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), NOW())');
$id = 'cl'.substr(strtolower(base_convert(random_bytes(12), 2, 36)), 0, 24);
$stmt->execute([
$id,
'admin@admin.com',
'Admin',
'User',
true,
json_encode(['ROLE_USER', 'ROLE_ADMIN']),
$hashedPassword,
]);
echo "User created!\n";
}
} else {
echo "Table 'profiles' does not exist yet. Run migrations first.\n";
}
echo "\nTest credentials:\n";
echo "Email: admin@admin.com\n";
echo "Password: admin123\n";

View File

@@ -14,6 +14,7 @@ services:
XDEBUG_CLIENT_HOST: ${XDEBUG_CLIENT_HOST:-host.docker.internal}
XDEBUG_CONFIG: client_host=${XDEBUG_CLIENT_HOST:-host.docker.internal} client_port=9003
DATABASE_URL: "postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?serverVersion=16&charset=utf8"
CORS_ALLOW_ORIGIN: ${CORS_ALLOW_ORIGIN}
volumes:
- ./:/var/www/html
- ~/.cache:/var/www/.cache # Pour la cache de composer
@@ -29,8 +30,8 @@ services:
depends_on:
- db
ports:
- "8080:80"
- "3000:3000"
- "8081:80"
- "3001:3000"
restart: unless-stopped
db:
image: postgres:16-alpine
@@ -41,7 +42,20 @@ services:
volumes:
- pg_data:/var/lib/postgresql/data
ports:
- "${POSTGRES_PORT:-5432}:5432"
- "${POSTGRES_PORT:-5433}:5432"
restart: unless-stopped
adminer:
container_name: adminer-${DOCKER_APP_NAME}
image: adminer:latest
environment:
ADMINER_DEFAULT_SERVER: db
ADMINER_DESIGN: dracula
ports:
- "${ADMINER_PORT:-5050}:8080"
depends_on:
- db
restart: unless-stopped
volumes:
pg_data:

View File

@@ -1,9 +1,9 @@
DOCKER_APP_NAME=ferme
DOCKER_APP_NAME=inventory
DOCKER_PHP_VERSION=8.4.6
DOCKER_NODE_VERSION=24.12.0
APP_USER=www-data
POSTGRES_DB=ferme
POSTGRES_DB=inventory
POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_PORT=5432
XDEBUG_CLIENT_HOST=host.docker.internal
XDEBUG_CLIENT_HOST=host.docker.internal

35
docker/.env.docker.local Normal file
View File

@@ -0,0 +1,35 @@
DOCKER_APP_NAME=inventory
DOCKER_PHP_VERSION=8.4.6
DOCKER_NODE_VERSION=24.12.0
APP_USER=www-data
CURRENT_UID=1000
CURRENT_GID=1000
# PostgreSQL
POSTGRES_DB=inventory
POSTGRES_USER=root
POSTGRES_PASSWORD=root
#
# CORS
CORS_ALLOW_ORIGIN=^https?://(localhost|127\\.0\\.0\\.1)(:[0-9]+)?$
POSTGRES_PORT=5433
# pgAdmin
PGADMIN_EMAIL=admin@admin.com
PGADMIN_PASSWORD=admin
PGADMIN_PORT=5050
# XDebug
XDEBUG_CLIENT_HOST=host.docker.internal
# Symfony (pour future migration)
APP_ENV=dev
APP_SECRET=changeme_super_secret_key_123456789
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=your_jwt_passphrase_change_me
# NestJS
NESTJS_PORT=3000
SESSION_SECRET=changeme_session_secret
CORS_ORIGIN=http://localhost:3001

2
docker/pgadmin/pgpass Normal file
View File

@@ -0,0 +1,2 @@
db:5432:inventory:root:root
db:5432:*:root:root

View File

@@ -0,0 +1,15 @@
{
"Servers": {
"1": {
"Name": "Inventory PostgreSQL",
"Group": "Servers",
"Host": "db",
"Port": 5432,
"MaintenanceDB": "inventory",
"Username": "root",
"SSLMode": "prefer",
"PassFile": "/var/lib/pgadmin/pgpass",
"Comment": "Serveur PostgreSQL du projet Inventory"
}
}
}

View File

@@ -33,6 +33,7 @@ RUN apt-get update && apt-get install -y \
wget \
git \
unzip \
qpdf \
&& docker-php-ext-install -j$(nproc) \
intl \
zip \

View File

@@ -1,26 +1,15 @@
<VirtualHost *:80>
DocumentRoot /var/www/html
ServerName localhost
DocumentRoot /var/www/html/public
AliasMatch "^/api(/.*)?" "/var/www/html/public$1"
# API Symfony
<Directory /var/www/html/public>
Options FollowSymLinks
Options +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
AliasMatch "^(/.*)?" "/var/www/html/frontend/dist$1"
<Directory /var/www/html/frontend/dist>
AllowOverride All
Order allow,deny
Allow from All
RewriteEngine on
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
RewriteRule ^ index.html [L]
</Directory>
ErrorLog "${APACHE_LOG_DIR}/error.log"
CustomLog "${APACHE_LOG_DIR}/access.log" combined
# Logs
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

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

1092
fixtures/data.sql Normal file

File diff suppressed because one or more lines are too long

42
fixtures/load.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Load fixtures into the database
# Usage: ./fixtures/load.sh [--reset]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
# Load environment variables
if [ -f "$PROJECT_DIR/.env" ]; then
export $(grep -v '^#' "$PROJECT_DIR/.env" | xargs)
fi
DB_USER="${POSTGRES_USER:-root}"
DB_NAME="${POSTGRES_DB:-inventory}"
CONTAINER="${DB_CONTAINER:-inventory-db-1}"
echo "Loading fixtures into $DB_NAME..."
# Check if --reset flag is passed
if [ "$1" == "--reset" ]; then
echo "Resetting database (truncating all tables)..."
docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -c "
DO \$\$ DECLARE
r RECORD;
BEGIN
FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename != 'doctrine_migration_versions') LOOP
EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE';
END LOOP;
END \$\$;
"
fi
# Load fixtures with foreign key checks disabled
docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" <<EOF
SET session_replication_role = replica;
$(cat "$SCRIPT_DIR/data.sql")
SET session_replication_role = DEFAULT;
EOF
echo "Fixtures loaded successfully!"

24
frontend/.gitignore vendored
View File

@@ -1,24 +0,0 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

View File

@@ -1,75 +0,0 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

View File

@@ -1,3 +0,0 @@
<template>
<NuxtPage/>
</template>

View File

@@ -1,9 +0,0 @@
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
ssr: false,
modules: ['@nuxtjs/tailwindcss'],
typescript: {
strict: true
}
})

11892
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +0,0 @@
{
"name": "frontend",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
},
"dependencies": {
"nuxt": "^4.2.2",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.14.0"
}
}

View File

@@ -1,9 +0,0 @@
<template>
<div class="min-h-screen flex items-center justify-center">
<h1 class="text-3xl font-bold">Nuxt OK </h1>
</div>
</template>
<script setup lang="ts">
</script>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,2 +0,0 @@
User-Agent: *
Disallow:

View File

@@ -1,18 +0,0 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}

View File

@@ -19,6 +19,8 @@ EXEC_PHP_ROOT = $(DOCKER) exec -t -u root $(PHP_CONTAINER)
EXEC_PHP_INTERACTIVE = $(DOCKER) exec -it -u $(APP_USER) $(PHP_CONTAINER)
EXEC_PHP_INTERACTIVE_ROOT = $(DOCKER) exec -it -u root $(PHP_CONTAINER)
FILES =
DATA_SQL ?= data.sql
DATA_SQL_NORM ?= data_norm.sql
#========================================================================================
@@ -31,6 +33,11 @@ start: env-init
@echo "**** START CONTAINERS ****"
@cp --update=none docker/.env.docker docker/.env.docker.local
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
@echo ""
@echo "URLs disponibles:"
@echo "- Symfony API: http://localhost:8081/api"
@echo "- Nuxt (Inventory_frontend): http://localhost:3001"
@echo "- adminer: http://localhost:5050"
# Éteint le container
stop:
@@ -49,16 +56,16 @@ composer-install:
$(EXEC_PHP) composer install
build-nuxtJS:
# $(EXEC_PHP) cp -n frontend/.env.dist frontend/.env.local
$(EXEC_PHP) sh -lc "cd frontend && npm install && npm run build:dist"
# $(EXEC_PHP) cp -n Inventory_frontend/.env.dist Inventory_frontend/.env.local
$(EXEC_PHP) sh -lc "cd Inventory_frontend && npm install && npm run generate"
dev-nuxt:
$(EXEC_PHP) sh -c "cd frontend && npm run dev"
$(EXEC_PHP) sh -lc "cd Inventory_frontend && npm run dev"
delete_built_dir:
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
$(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf vendor/
$(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf frontend/node_modules
$(DOCKER) exec -u root $(PHP_CONTAINER) rm -rf Inventory_frontend/node_modules
remove_orphans:
$(DOCKER_COMPOSE) kill
@@ -85,6 +92,10 @@ db-restart:
cache-clear:
$(SYMFONY_CONSOLE) cache:clear
cache-clear-full:
$(SYMFONY_CONSOLE) cache:clear
$(EXEC_PHP) rm -rf var/cache/*
copy-git-hook:
$(EXEC_PHP) cp pre-commit .git/hooks/
$(EXEC_PHP) cp commit-msg .git/hooks/
@@ -106,5 +117,37 @@ 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
# Normalize pgAdmin data-only dump and import into DB
import-data:
python3 scripts/normalize-dump.py $(DATA_SQL) $(DATA_SQL_NORM) --lower
$(DOCKER_COMPOSE) exec -T db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) -v ON_ERROR_STOP=1 -c "SET session_replication_role = replica;"
$(DOCKER_COMPOSE) exec -T db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) -v ON_ERROR_STOP=1 < $(DATA_SQL_NORM)
$(DOCKER_COMPOSE) exec -T db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) -v ON_ERROR_STOP=1 -c "SET session_replication_role = DEFAULT;"
# Fixtures management
fixtures-dump:
@echo "Dumping current database to fixtures/data.sql..."
$(DOCKER_COMPOSE) exec -T db pg_dump -U $(POSTGRES_USER) -d $(POSTGRES_DB) \
--data-only --inserts --no-owner --no-privileges \
--exclude-table=doctrine_migration_versions \
| grep -v "^pg_dump:" | grep -v "^\\\\restrict" > fixtures/data.sql
@echo "Fixtures saved to fixtures/data.sql"
fixtures-load:
@echo "Loading fixtures from fixtures/data.sql (FK checks disabled)..."
$(DOCKER_COMPOSE) exec -T db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) -c "SET session_replication_role = replica;"
-$(DOCKER_COMPOSE) exec -T db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) < fixtures/data.sql
$(DOCKER_COMPOSE) exec -T db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) -c "SET session_replication_role = DEFAULT;"
@echo "Fixtures loaded!"
fixtures-reset:
@echo "Resetting database and loading fixtures..."
$(DOCKER_COMPOSE) exec -T db psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) -c "DO \$$\$$ DECLARE r RECORD; BEGIN FOR r IN (SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename != 'doctrine_migration_versions') LOOP EXECUTE 'TRUNCATE TABLE ' || quote_ident(r.tablename) || ' CASCADE'; END LOOP; END \$$\$$;"
$(MAKE) fixtures-load

View File

@@ -0,0 +1,891 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260125143939 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f95a3199df92e79b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f95a3199df92e79b RENAME TO IDX_F95A3199CC8A4CEE';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f95a3199a3fdb2a7') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f95a3199a3fdb2a7 RENAME TO IDX_F95A319936799605';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE _composantconstructeurs DROP CONSTRAINT IF EXISTS "_ComposantConstructeurs_A_fkey"');
$this->addSql('ALTER TABLE _composantconstructeurs DROP CONSTRAINT IF EXISTS "_ComposantConstructeurs_B_fkey"');
$this->addSql('ALTER TABLE _composantconstructeurs ALTER A TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE _composantconstructeurs ALTER B TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE _composantconstructeurs ADD CONSTRAINT FK_60760125D3D99E8B FOREIGN KEY (A) REFERENCES composants (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _composantconstructeurs ADD CONSTRAINT FK_607601254AD0CF31 FOREIGN KEY (B) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _composantconstructeurs ADD PRIMARY KEY (A, B)');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_5b97d813e8b7be43') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_5b97d813e8b7be43 RENAME TO IDX_60760125D3D99E8B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('_composantconstructeurs_b_index') IS NOT NULL THEN
EXECUTE 'ALTER INDEX _composantconstructeurs_b_index RENAME TO IDX_607601254AD0CF31';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ff6736d61') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ff6736d61 RENAME TO IDX_6B64D7FF5C4A705F';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7fff6bae05f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7fff6bae05f RENAME TO IDX_6B64D7FF633EC4FD';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ffa1dac1c6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ffa1dac1c6 RENAME TO IDX_6B64D7FF345EE564';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ff96428d73') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ff96428d73 RENAME TO IDX_6B64D7FF3C6A9D1';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ffa3fdb2a7') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ffa3fdb2a7 RENAME TO IDX_6B64D7FF36799605';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378c158582c3') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378c158582c3 RENAME TO IDX_4A48378C2F024C2';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378cdf92e79b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378cdf92e79b RENAME TO IDX_4A48378CCC8A4CEE';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378c4ca601c8') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378c4ca601c8 RENAME TO IDX_4A48378C169F1CF6';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378c40c2d03b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378c40c2d03b RENAME TO IDX_4A48378C57B7763A';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b07288f6bae05f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b07288f6bae05f RENAME TO IDX_A2B07288633EC4FD';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b07288a1dac1c6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b07288a1dac1c6 RENAME TO IDX_A2B07288345EE564';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b0728896428d73') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b0728896428d73 RENAME TO IDX_A2B072883C6A9D1';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b07288a3fdb2a7') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b07288a3fdb2a7 RENAME TO IDX_A2B0728836799605';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b07288fcf7805f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b07288fcf7805f RENAME TO IDX_A2B072886973A4FD';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe19f6bae05f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe19f6bae05f RENAME TO IDX_528EFE19633EC4FD';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe19a1dac1c6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe19a1dac1c6 RENAME TO IDX_528EFE19345EE564';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe197d44d2df') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe197d44d2df RENAME TO IDX_528EFE19EF6CF34B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe19bcced9e3') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe19bcced9e3 RENAME TO IDX_528EFE19C44B383C';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_62941615f6bae05f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_62941615f6bae05f RENAME TO IDX_62941615633EC4FD';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6294161596428d73') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6294161596428d73 RENAME TO IDX_629416153C6A9D1';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_629416157d44d2df') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_629416157d44d2df RENAME TO IDX_62941615EF6CF34B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6294161532c54aaf') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6294161532c54aaf RENAME TO IDX_62941615F957D314';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('machine_product_links_machineid_idx') IS NOT NULL THEN
EXECUTE 'ALTER INDEX machine_product_links_machineid_idx RENAME TO IDX_8CC32259633EC4FD';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('machine_product_links_productid_idx') IS NOT NULL THEN
EXECUTE 'ALTER INDEX machine_product_links_productid_idx RENAME TO IDX_8CC3225936799605';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc32259357fdbff') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc32259357fdbff RENAME TO IDX_8CC32259B590B209';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc322597d44d2df') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc322597d44d2df RENAME TO IDX_8CC32259EF6CF34B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc32259bcd7dad6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc32259bcd7dad6 RENAME TO IDX_8CC32259A63AC5DC';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc3225987ceb33f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc3225987ceb33f RENAME TO IDX_8CC32259937A1D7C';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f1ce8dedfcf7805f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f1ce8dedfcf7805f RENAME TO IDX_F1CE8DED6973A4FD';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f1ce8ded158582c3') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f1ce8ded158582c3 RENAME TO IDX_F1CE8DED2F024C2';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE _machineconstructeurs DROP CONSTRAINT IF EXISTS "_MachineConstructeurs_B_fkey"');
$this->addSql('ALTER TABLE _machineconstructeurs DROP CONSTRAINT IF EXISTS "_MachineConstructeurs_A_fkey"');
$this->addSql('ALTER TABLE _machineconstructeurs ALTER A TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE _machineconstructeurs ALTER B TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE _machineconstructeurs ADD CONSTRAINT FK_E6A040CCD3D99E8B FOREIGN KEY (A) REFERENCES machines (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _machineconstructeurs ADD CONSTRAINT FK_E6A040CC4AD0CF31 FOREIGN KEY (B) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _machineconstructeurs ADD PRIMARY KEY (A, B)');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4f225b32e8b7be43') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4f225b32e8b7be43 RENAME TO IDX_E6A040CCD3D99E8B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('_machineconstructeurs_b_index') IS NOT NULL THEN
EXECUTE 'ALTER INDEX _machineconstructeurs_b_index RENAME TO IDX_E6A040CC4AD0CF31';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE model_types DROP CONSTRAINT IF EXISTS "ModelType_category_name_key"');
$this->addSql('ALTER TABLE model_types DROP CONSTRAINT IF EXISTS "ModelType_code_key"');
$this->addSql('ALTER TABLE model_types ALTER id TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE model_types ALTER category TYPE VARCHAR(255)');
$this->addSql('ALTER TABLE model_types ALTER createdAt DROP DEFAULT');
$this->addSql('ALTER TABLE model_types ALTER componentSkeleton TYPE JSON');
$this->addSql('ALTER TABLE model_types ALTER pieceSkeleton TYPE JSON');
$this->addSql('ALTER TABLE model_types ALTER productSkeleton TYPE JSON');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_b92d74724ca601c8') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_b92d74724ca601c8 RENAME TO IDX_B92D7472169F1CF6';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_b92d7472a3fdb2a7') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_b92d7472a3fdb2a7 RENAME TO IDX_B92D747236799605';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE _piececonstructeurs DROP CONSTRAINT IF EXISTS "_PieceConstructeurs_A_fkey"');
$this->addSql('ALTER TABLE _piececonstructeurs DROP CONSTRAINT IF EXISTS "_PieceConstructeurs_B_fkey"');
$this->addSql('ALTER TABLE _piececonstructeurs ALTER A TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE _piececonstructeurs ALTER B TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE _piececonstructeurs ADD CONSTRAINT FK_E94732E5D3D99E8B FOREIGN KEY (A) REFERENCES pieces (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _piececonstructeurs ADD CONSTRAINT FK_E94732E54AD0CF31 FOREIGN KEY (B) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _piececonstructeurs ADD PRIMARY KEY (A, B)');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_77fc120e8b7be43') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_77fc120e8b7be43 RENAME TO IDX_E94732E5D3D99E8B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('_piececonstructeurs_b_index') IS NOT NULL THEN
EXECUTE 'ALTER INDEX _piececonstructeurs_b_index RENAME TO IDX_E94732E54AD0CF31';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_b3ba5a5a40c2d03b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_b3ba5a5a40c2d03b RENAME TO IDX_B3BA5A5A57B7763A';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE _productconstructeurs DROP CONSTRAINT IF EXISTS "_ProductConstructeurs_B_fkey"');
$this->addSql('ALTER TABLE _productconstructeurs DROP CONSTRAINT IF EXISTS "_ProductConstructeurs_A_fkey"');
$this->addSql('ALTER TABLE _productconstructeurs ALTER A TYPE VARCHAR(36)');
$this->addSql('ALTER TABLE _productconstructeurs ALTER B TYPE VARCHAR(36)');
// Clean orphaned relations before re-adding foreign keys.
$this->addSql('DELETE FROM _productconstructeurs WHERE A IS NULL OR B IS NULL');
$this->addSql('DELETE FROM _productconstructeurs pc WHERE NOT EXISTS (SELECT 1 FROM products p WHERE p.id = pc.A)');
$this->addSql('DELETE FROM _productconstructeurs pc WHERE NOT EXISTS (SELECT 1 FROM constructeurs c WHERE c.id = pc.B)');
$this->addSql('ALTER TABLE _productconstructeurs ADD CONSTRAINT FK_CF7403FCD3D99E8B FOREIGN KEY (A) REFERENCES products (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _productconstructeurs ADD CONSTRAINT FK_CF7403FC4AD0CF31 FOREIGN KEY (B) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE _productconstructeurs ADD PRIMARY KEY (A, B)');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_66f61802e8b7be43') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_66f61802e8b7be43 RENAME TO IDX_CF7403FCD3D99E8B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('_productconstructeurs_b_index') IS NOT NULL THEN
EXECUTE 'ALTER INDEX _productconstructeurs_b_index RENAME TO IDX_CF7403FC4AD0CF31';
END IF;
END $$;
SQL
);
$this->addSql('DROP INDEX IF EXISTS uniq_profiles_email');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_96958790158582c3') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_96958790158582c3 RENAME TO IDX_969587902F024C2';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_96958790df92e79b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_96958790df92e79b RENAME TO IDX_96958790CC8A4CEE';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f609e59e158582c3') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f609e59e158582c3 RENAME TO IDX_F609E59E2F024C2';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f609e59e4ca601c8') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f609e59e4ca601c8 RENAME TO IDX_F609E59E169F1CF6';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_29a51f98158582c3') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_29a51f98158582c3 RENAME TO IDX_29A51F982F024C2';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_29a51f9840c2d03b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_29a51f9840c2d03b RENAME TO IDX_29A51F9857B7763A';
END IF;
END $$;
SQL
);
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT IF EXISTS FK_60760125D3D99E8B');
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT IF EXISTS FK_607601254AD0CF31');
$this->addSql('ALTER TABLE _ComposantConstructeurs DROP CONSTRAINT IF EXISTS _ComposantConstructeurs_pkey');
$this->addSql('ALTER TABLE _ComposantConstructeurs ALTER a TYPE TEXT');
$this->addSql('ALTER TABLE _ComposantConstructeurs ALTER b TYPE TEXT');
$this->addSql('ALTER TABLE _ComposantConstructeurs ADD CONSTRAINT "_ComposantConstructeurs_A_fkey" FOREIGN KEY (a) REFERENCES composants (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _ComposantConstructeurs ADD CONSTRAINT "_ComposantConstructeurs_B_fkey" FOREIGN KEY (b) REFERENCES constructeurs (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_607601254ad0cf31') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_607601254ad0cf31 RENAME TO "_ComposantConstructeurs_B_index"';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_60760125d3d99e8b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_60760125d3d99e8b RENAME TO IDX_5B97D813E8B7BE43';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT IF EXISTS FK_E6A040CCD3D99E8B');
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT IF EXISTS FK_E6A040CC4AD0CF31');
$this->addSql('ALTER TABLE _MachineConstructeurs DROP CONSTRAINT IF EXISTS _MachineConstructeurs_pkey');
$this->addSql('ALTER TABLE _MachineConstructeurs ALTER a TYPE TEXT');
$this->addSql('ALTER TABLE _MachineConstructeurs ALTER b TYPE TEXT');
$this->addSql('ALTER TABLE _MachineConstructeurs ADD CONSTRAINT "_MachineConstructeurs_B_fkey" FOREIGN KEY (b) REFERENCES constructeurs (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _MachineConstructeurs ADD CONSTRAINT "_MachineConstructeurs_A_fkey" FOREIGN KEY (a) REFERENCES machines (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_e6a040cc4ad0cf31') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_e6a040cc4ad0cf31 RENAME TO "_MachineConstructeurs_B_index"';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_e6a040ccd3d99e8b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_e6a040ccd3d99e8b RENAME TO IDX_4F225B32E8B7BE43';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT IF EXISTS FK_E94732E5D3D99E8B');
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT IF EXISTS FK_E94732E54AD0CF31');
$this->addSql('ALTER TABLE _PieceConstructeurs DROP CONSTRAINT IF EXISTS _PieceConstructeurs_pkey');
$this->addSql('ALTER TABLE _PieceConstructeurs ALTER a TYPE TEXT');
$this->addSql('ALTER TABLE _PieceConstructeurs ALTER b TYPE TEXT');
$this->addSql('ALTER TABLE _PieceConstructeurs ADD CONSTRAINT "_PieceConstructeurs_A_fkey" FOREIGN KEY (a) REFERENCES pieces (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _PieceConstructeurs ADD CONSTRAINT "_PieceConstructeurs_B_fkey" FOREIGN KEY (b) REFERENCES constructeurs (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_e94732e54ad0cf31') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_e94732e54ad0cf31 RENAME TO "_PieceConstructeurs_B_index"';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_e94732e5d3d99e8b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_e94732e5d3d99e8b RENAME TO IDX_77FC120E8B7BE43';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT IF EXISTS FK_CF7403FCD3D99E8B');
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT IF EXISTS FK_CF7403FC4AD0CF31');
$this->addSql('ALTER TABLE _ProductConstructeurs DROP CONSTRAINT IF EXISTS _ProductConstructeurs_pkey');
$this->addSql('ALTER TABLE _ProductConstructeurs ALTER a TYPE TEXT');
$this->addSql('ALTER TABLE _ProductConstructeurs ALTER b TYPE TEXT');
$this->addSql('ALTER TABLE _ProductConstructeurs ADD CONSTRAINT "_ProductConstructeurs_B_fkey" FOREIGN KEY (b) REFERENCES products (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _ProductConstructeurs ADD CONSTRAINT "_ProductConstructeurs_A_fkey" FOREIGN KEY (a) REFERENCES constructeurs (id) ON UPDATE CASCADE ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_cf7403fc4ad0cf31') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_cf7403fc4ad0cf31 RENAME TO "_ProductConstructeurs_B_index"';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_cf7403fcd3d99e8b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_cf7403fcd3d99e8b RENAME TO IDX_66F61802E8B7BE43';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f95a319936799605') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f95a319936799605 RENAME TO IDX_F95A3199A3FDB2A7';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f95a3199cc8a4cee') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f95a3199cc8a4cee RENAME TO IDX_F95A3199DF92E79B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ff345ee564') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ff345ee564 RENAME TO IDX_6B64D7FFA1DAC1C6';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ff5c4a705f') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ff5c4a705f RENAME TO IDX_6B64D7FF6736D61';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ff633ec4fd') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ff633ec4fd RENAME TO IDX_6B64D7FFF6BAE05F';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ff3c6a9d1') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ff3c6a9d1 RENAME TO IDX_6B64D7FF96428D73';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_6b64d7ff36799605') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_6b64d7ff36799605 RENAME TO IDX_6B64D7FFA3FDB2A7';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378c57b7763a') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378c57b7763a RENAME TO IDX_4A48378C40C2D03B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378c2f024c2') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378c2f024c2 RENAME TO IDX_4A48378C158582C3';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378c169f1cf6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378c169f1cf6 RENAME TO IDX_4A48378C4CA601C8';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_4a48378ccc8a4cee') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_4a48378ccc8a4cee RENAME TO IDX_4A48378CDF92E79B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b07288345ee564') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b07288345ee564 RENAME TO IDX_A2B07288A1DAC1C6';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b07288633ec4fd') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b07288633ec4fd RENAME TO IDX_A2B07288F6BAE05F';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b072886973a4fd') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b072886973a4fd RENAME TO IDX_A2B07288FCF7805F';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b072883c6a9d1') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b072883c6a9d1 RENAME TO IDX_A2B0728896428D73';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_a2b0728836799605') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_a2b0728836799605 RENAME TO IDX_A2B07288A3FDB2A7';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe19345ee564') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe19345ee564 RENAME TO IDX_528EFE19A1DAC1C6';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe19633ec4fd') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe19633ec4fd RENAME TO IDX_528EFE19F6BAE05F';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe19ef6cf34b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe19ef6cf34b RENAME TO IDX_528EFE197D44D2DF';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_528efe19c44b383c') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_528efe19c44b383c RENAME TO IDX_528EFE19BCCED9E3';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_62941615ef6cf34b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_62941615ef6cf34b RENAME TO IDX_629416157D44D2DF';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_62941615633ec4fd') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_62941615633ec4fd RENAME TO IDX_62941615F6BAE05F';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_629416153c6a9d1') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_629416153c6a9d1 RENAME TO IDX_6294161596428D73';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_62941615f957d314') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_62941615f957d314 RENAME TO IDX_6294161532C54AAF';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc32259633ec4fd') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc32259633ec4fd RENAME TO "machine_product_links_machineId_idx"';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc3225936799605') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc3225936799605 RENAME TO "machine_product_links_productId_idx"';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc32259ef6cf34b') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc32259ef6cf34b RENAME TO IDX_8CC322597D44D2DF';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc32259b590b209') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc32259b590b209 RENAME TO IDX_8CC32259357FDBFF';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc32259a63ac5dc') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc32259a63ac5dc RENAME TO IDX_8CC32259BCD7DAD6';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_8cc32259937a1d7c') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_8cc32259937a1d7c RENAME TO IDX_8CC3225987CEB33F';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f1ce8ded2f024c2') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f1ce8ded2f024c2 RENAME TO IDX_F1CE8DED158582C3';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f1ce8ded6973a4fd') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f1ce8ded6973a4fd RENAME TO IDX_F1CE8DEDFCF7805F';
END IF;
END $$;
SQL
);
$this->addSql('ALTER TABLE model_types ALTER id TYPE TEXT');
$this->addSql('ALTER TABLE model_types ALTER category TYPE VARCHAR');
$this->addSql('ALTER TABLE model_types ALTER componentskeleton TYPE JSONB');
$this->addSql('ALTER TABLE model_types ALTER pieceskeleton TYPE JSONB');
$this->addSql('ALTER TABLE model_types ALTER productskeleton TYPE JSONB');
$this->addSql('ALTER TABLE model_types ALTER createdat SET DEFAULT CURRENT_TIMESTAMP');
$this->addSql('CREATE UNIQUE INDEX "ModelType_category_name_key" ON model_types (category, name)');
$this->addSql('CREATE UNIQUE INDEX "ModelType_code_key" ON model_types (code)');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_b92d7472169f1cf6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_b92d7472169f1cf6 RENAME TO IDX_B92D74724CA601C8';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_b92d747236799605') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_b92d747236799605 RENAME TO IDX_B92D7472A3FDB2A7';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_b3ba5a5a57b7763a') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_b3ba5a5a57b7763a RENAME TO IDX_B3BA5A5A40C2D03B';
END IF;
END $$;
SQL
);
$this->addSql('CREATE UNIQUE INDEX uniq_profiles_email ON profiles (email)');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_969587902f024c2') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_969587902f024c2 RENAME TO IDX_96958790158582C3';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_96958790cc8a4cee') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_96958790cc8a4cee RENAME TO IDX_96958790DF92E79B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f609e59e169f1cf6') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f609e59e169f1cf6 RENAME TO IDX_F609E59E4CA601C8';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_f609e59e2f024c2') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_f609e59e2f024c2 RENAME TO IDX_F609E59E158582C3';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_29a51f9857b7763a') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_29a51f9857b7763a RENAME TO IDX_29A51F9840C2D03B';
END IF;
END $$;
SQL
);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF to_regclass('idx_29a51f982f024c2') IS NOT NULL THEN
EXECUTE 'ALTER INDEX idx_29a51f982f024c2 RENAME TO IDX_29A51F98158582C3';
END IF;
END $$;
SQL
);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260125170000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add audit_logs table to store per-entity history entries.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE audit_logs (
id VARCHAR(36) NOT NULL,
entityType VARCHAR(50) NOT NULL,
entityId VARCHAR(36) NOT NULL,
action VARCHAR(20) NOT NULL,
diff JSON DEFAULT NULL,
snapshot JSON DEFAULT NULL,
actorProfileId VARCHAR(36) DEFAULT NULL,
createdAt TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
PRIMARY KEY(id)
)
SQL);
$this->addSql('CREATE INDEX idx_audit_entity ON audit_logs (entityType, entityId)');
$this->addSql('CREATE INDEX idx_audit_created_at ON audit_logs (createdAt)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE audit_logs');
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260302103003 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create comments table + make piece reference unique instead of name';
}
public function up(Schema $schema): void
{
// Comments table (IF NOT EXISTS in case first attempt partially succeeded)
$this->addSql('CREATE TABLE IF NOT EXISTS comments (id VARCHAR(36) NOT NULL, content TEXT NOT NULL, entity_type VARCHAR(50) NOT NULL, entity_id VARCHAR(36) NOT NULL, entity_name VARCHAR(255) DEFAULT NULL, author_id VARCHAR(36) NOT NULL, author_name VARCHAR(255) NOT NULL, status VARCHAR(20) NOT NULL, resolved_by_id VARCHAR(36) DEFAULT NULL, resolved_by_name VARCHAR(255) DEFAULT NULL, resolved_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comment_entity_status ON comments (entity_type, entity_id, status)');
$this->addSql('COMMENT ON COLUMN comments.resolved_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN comments.created_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN comments.updated_at IS \'(DC2Type:datetime_immutable)\'');
// Piece: remove unique constraint on name (it's a constraint, not just an index)
$this->addSql('ALTER TABLE pieces DROP CONSTRAINT IF EXISTS uniq_b92d74725e237e06');
// Deduplicate piece references before adding unique constraint
$this->addSql("
UPDATE pieces p
SET reference = p.reference || '-' || LEFT(p.id, 6)
FROM (
SELECT id, reference,
ROW_NUMBER() OVER (PARTITION BY reference ORDER BY createdat) AS rn
FROM pieces
WHERE reference IS NOT NULL AND reference != ''
) dup
WHERE p.id = dup.id AND dup.rn > 1
");
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_pieces_reference ON pieces (reference)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS comments');
$this->addSql('DROP INDEX IF EXISTS uniq_pieces_reference');
$this->addSql('CREATE UNIQUE INDEX uniq_b92d74725e237e06 ON pieces (name)');
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260302120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add description column to pieces and composants tables';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS description TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE composants ADD COLUMN IF NOT EXISTS description TEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS description');
$this->addSql('ALTER TABLE composants DROP COLUMN IF EXISTS description');
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260304120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Remove TypeMachine skeleton system, link custom fields directly to machines';
}
public function up(Schema $schema): void
{
// 1. Drop requirement FK columns on link tables
$this->addSql('ALTER TABLE machine_component_links DROP COLUMN IF EXISTS typemachinecomponentrequirementid');
$this->addSql('ALTER TABLE machine_piece_links DROP COLUMN IF EXISTS typemachinepiecerequirementid');
$this->addSql('ALTER TABLE machine_product_links DROP COLUMN IF EXISTS typemachineproductrequirementid');
// 2. Add machineid column to custom_fields (new direct FK to machines)
$this->addSql('ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machineid VARCHAR(36) DEFAULT NULL');
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'fk_custom_fields_machine' AND table_name = 'custom_fields'
) THEN
ALTER TABLE custom_fields ADD CONSTRAINT fk_custom_fields_machine
FOREIGN KEY (machineid) REFERENCES machines(id) ON DELETE CASCADE;
END IF;
END $$;
SQL);
// 3. Enable pgcrypto for gen_random_bytes (needed for CUID generation)
$this->addSql('CREATE EXTENSION IF NOT EXISTS pgcrypto');
// 4. Migrate existing custom fields: copy from TypeMachine to each linked Machine
$this->addSql(<<<'SQL'
INSERT INTO custom_fields (id, name, type, required, defaultvalue, options, orderindex, machineid, createdat, updatedat)
SELECT
'cl' || encode(gen_random_bytes(12), 'hex'),
cf.name, cf.type, cf.required, cf.defaultvalue, cf.options, cf.orderindex,
m.id,
NOW(), NOW()
FROM custom_fields cf
JOIN machines m ON m.typemachineid = cf.typemachineid
WHERE cf.typemachineid IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM custom_fields existing
WHERE existing.machineid = m.id AND existing.name = cf.name
)
SQL);
// 4. Delete original TypeMachine-linked custom fields (now migrated)
$this->addSql('DELETE FROM custom_fields WHERE typemachineid IS NOT NULL');
// 5. Drop typemachineid column from custom_fields
$this->addSql('ALTER TABLE custom_fields DROP COLUMN IF EXISTS typemachineid');
// 6. Drop typemachineid column from machines
$this->addSql('ALTER TABLE machines DROP COLUMN IF EXISTS typemachineid');
// 7. Drop requirement tables (order matters: these reference type_machines)
$this->addSql('DROP TABLE IF EXISTS type_machine_component_requirements');
$this->addSql('DROP TABLE IF EXISTS type_machine_piece_requirements');
$this->addSql('DROP TABLE IF EXISTS type_machine_product_requirements');
// 8. Drop type_machines table
$this->addSql('DROP TABLE IF EXISTS type_machines');
}
public function down(Schema $schema): void
{
// Recreate type_machines table
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS type_machines (
id VARCHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT DEFAULT NULL,
category VARCHAR(255) DEFAULT NULL,
maintenancefrequency VARCHAR(255) DEFAULT NULL,
components JSON DEFAULT NULL,
criticalparts JSON DEFAULT NULL,
machinepieces JSON DEFAULT NULL,
specifications JSON DEFAULT NULL,
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
// Recreate requirement tables
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS type_machine_component_requirements (
id VARCHAR(36) NOT NULL PRIMARY KEY,
typemachineid VARCHAR(36) NOT NULL REFERENCES type_machines(id) ON DELETE CASCADE,
typecomposantid VARCHAR(36) NOT NULL REFERENCES model_types(id),
label VARCHAR(255) DEFAULT NULL,
mincount INTEGER DEFAULT 1,
maxcount INTEGER DEFAULT NULL,
required BOOLEAN DEFAULT true,
allownewmodels BOOLEAN DEFAULT true,
orderindex INTEGER DEFAULT 0,
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS type_machine_piece_requirements (
id VARCHAR(36) NOT NULL PRIMARY KEY,
typemachineid VARCHAR(36) NOT NULL REFERENCES type_machines(id) ON DELETE CASCADE,
typepieceid VARCHAR(36) NOT NULL REFERENCES model_types(id),
label VARCHAR(255) DEFAULT NULL,
mincount INTEGER DEFAULT 0,
maxcount INTEGER DEFAULT NULL,
required BOOLEAN DEFAULT false,
allownewmodels BOOLEAN DEFAULT true,
orderindex INTEGER DEFAULT 0,
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS type_machine_product_requirements (
id VARCHAR(36) NOT NULL PRIMARY KEY,
typemachineid VARCHAR(36) NOT NULL REFERENCES type_machines(id) ON DELETE CASCADE,
typeproductid VARCHAR(36) NOT NULL REFERENCES model_types(id),
label VARCHAR(255) DEFAULT NULL,
mincount INTEGER DEFAULT 0,
maxcount INTEGER DEFAULT NULL,
required BOOLEAN DEFAULT false,
allownewmodels BOOLEAN DEFAULT true,
orderindex INTEGER DEFAULT 0,
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
// Re-add typemachineid to machines
$this->addSql('ALTER TABLE machines ADD COLUMN IF NOT EXISTS typemachineid VARCHAR(36) DEFAULT NULL');
// Re-add typemachineid to custom_fields
$this->addSql('ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS typemachineid VARCHAR(36) DEFAULT NULL');
// Re-add requirement FK columns to link tables
$this->addSql('ALTER TABLE machine_component_links ADD COLUMN IF NOT EXISTS typemachinecomponentrequirementid VARCHAR(36) DEFAULT NULL');
$this->addSql('ALTER TABLE machine_piece_links ADD COLUMN IF NOT EXISTS typemachinepiecerequirementid VARCHAR(36) DEFAULT NULL');
$this->addSql('ALTER TABLE machine_product_links ADD COLUMN IF NOT EXISTS typemachineproductrequirementid VARCHAR(36) DEFAULT NULL');
// Drop machine FK on custom_fields
$this->addSql('ALTER TABLE custom_fields DROP COLUMN IF EXISTS machineid');
}
}

View File

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

View File

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

70
public/.htaccess Normal file
View File

@@ -0,0 +1,70 @@
# Use the front controller as index file. It serves as a fallback solution when
# every other rewrite/redirect fails (e.g. in an aliased environment without
# mod_rewrite). Additionally, this reduces the matching process for the
# start page (path "/") because otherwise Apache will apply the rewriting rules
# to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl).
DirectoryIndex index.php
# By default, Apache does not evaluate symbolic links if you did not enable this
# feature in your server configuration. Uncomment the following line if you
# install assets as symlinks or if you experience problems related to symlinks
# when compiling LESS/Sass/CoffeeScript assets.
# Options +FollowSymlinks
# Disabling MultiViews prevents unwanted negotiation, e.g. "/index" should not resolve
# to the front controller "/index.php" but be rewritten to "/index.php/index".
<IfModule mod_negotiation.c>
Options -MultiViews
</IfModule>
<IfModule mod_rewrite.c>
# This Option needs to be enabled for RewriteRule, otherwise it will show an error like
# 'Options FollowSymLinks or SymLinksIfOwnerMatch is off which implies that RewriteRule directive is forbidden'
Options +FollowSymlinks
RewriteEngine On
# Determine the RewriteBase automatically and set it as environment variable.
# If you are using Apache aliases to do mass virtual hosting or installed the
# project in a subdirectory, the base path will be prepended to allow proper
# resolution of the index.php file and to redirect to the correct URI. It will
# work in environments without path prefix as well, providing a safe, one-size
# fits all solution. But as you do not need it in this case, you can comment
# the following 2 lines to eliminate the overhead.
RewriteCond %{REQUEST_URI}::$0 ^(/.+)/(.*)::\2$
RewriteRule .* - [E=BASE:%1]
# Sets the HTTP_AUTHORIZATION header removed by Apache
RewriteCond %{HTTP:Authorization} .+
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0]
# Redirect to URI without front controller to prevent duplicate content
# (with and without `/index.php`). Only do this redirect on the initial
# rewrite by Apache and not on subsequent cycles. Otherwise we would get an
# endless redirect loop (request -> rewrite to front controller ->
# redirect to URI without front controller -> request -> ...).
# So in case you get a "too many redirects" error or you always get redirected
# to the start page because your Apache does not expose the REDIRECT_STATUS
# environment variable, you have 2 choices:
# - disable this feature by commenting the following 2 lines or
# - use Apache >= 2.3.9 and replace all L flags by END flags and remove the
# following RewriteCond (best solution)
RewriteCond %{ENV:REDIRECT_STATUS} =""
RewriteRule ^index\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L]
# If the requested filename exists, simply serve it.
# We only want to let Apache serve files and not directories.
# Rewrite all other queries to the front controller.
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ %{ENV:BASE}/index.php [L]
</IfModule>
<IfModule !mod_rewrite.c>
<IfModule mod_alias.c>
# When mod_rewrite is not available, we instruct a temporary redirect of
# the start page to the front controller explicitly so that the website
# and the generated links can still be used.
RedirectMatch 307 ^/$ /index.php/
# RedirectTemp cannot be used instead
</IfModule>
</IfModule>

View File

@@ -0,0 +1,5 @@
INSERT INTO public.profiles (id, firstname, lastname, email, isactive, createdat, updatedat)
VALUES
('admin-default-profile', 'Admin', 'General', 'admin@admin.fr', true, '2025-09-23 13:09:47.804', '2025-09-23 13:09:47.804'),
('cmhab2j3x003g47v77xhnm1ff', 'Elodie', 'Souriau', 'elodie@gg.fr', true, '2025-10-28 08:29:25.437', '2025-10-28 08:29:25.437')
ON CONFLICT (id) DO NOTHING;

View File

@@ -0,0 +1,32 @@
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT table_schema, table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND column_name <> lower(column_name)
ORDER BY table_name, column_name
LOOP
-- Skip if a lowercase version already exists to avoid collisions.
IF EXISTS (
SELECT 1
FROM information_schema.columns c
WHERE c.table_schema = r.table_schema
AND c.table_name = r.table_name
AND c.column_name = lower(r.column_name)
) THEN
RAISE NOTICE 'Skip %.%: % -> % (target exists)', r.table_name, r.column_name, r.column_name, lower(r.column_name);
ELSE
EXECUTE format(
'ALTER TABLE %I.%I RENAME COLUMN %I TO %I',
r.table_schema,
r.table_name,
r.column_name,
lower(r.column_name)
);
RAISE NOTICE 'Renamed %.%: % -> %', r.table_name, r.column_name, r.column_name, lower(r.column_name);
END IF;
END LOOP;
END $$;

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
SOURCE_DB="${SOURCE_DB:-inventory_data}"
TARGET_DB="${TARGET_DB:-inventory}"
PGHOST="${PGHOST:-localhost}"
PGPORT="${PGPORT:-5433}"
PGUSER="${PGUSER:-postgres}"
PGPASSWORD="${PGPASSWORD:-postgres}"
DUMP_FILE="${DUMP_FILE:-/tmp/inventory_data_dump.sql}"
export PGPASSWORD
EXCLUDE_TABLES=(
"doctrine_migration_versions"
"migration_versions"
"_prisma_migrations"
"profiles"
)
EXCLUDE_ARGS=()
for table in "${EXCLUDE_TABLES[@]}"; do
EXCLUDE_ARGS+=(--exclude-table-data="$table")
done
echo "Dumping data from ${SOURCE_DB}..."
pg_dump \
--data-only \
--inserts \
--no-owner \
--no-privileges \
"${EXCLUDE_ARGS[@]}" \
-h "${PGHOST}" \
-p "${PGPORT}" \
-U "${PGUSER}" \
"${SOURCE_DB}" > "${DUMP_FILE}"
echo "Restoring data into ${TARGET_DB}..."
psql \
-h "${PGHOST}" \
-p "${PGPORT}" \
-U "${PGUSER}" \
"${TARGET_DB}" < "${DUMP_FILE}"
echo "Done. Data copied from ${SOURCE_DB} to ${TARGET_DB}."

177
scripts/normalize-dump.py Normal file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
import re
import sys
INSERT_RE = re.compile(
r"(?P<prefix>INSERT\s+INTO\s+[^;]*?\()(?P<cols>[^)]*)(?P<suffix>\)\s+VALUES)",
re.IGNORECASE | re.DOTALL,
)
TABLE_RE = re.compile(
r"(?P<before>INSERT\s+INTO\s+)(?P<table>(?:\"[^\"]+\"\.|[A-Za-z_][\w$]*\.)?\"[^\"]+\"|(?:\"[^\"]+\"\.|[A-Za-z_][\w$]*\.)?[A-Za-z_][\w$]*)",
re.IGNORECASE,
)
CREATE_DB_RE = re.compile(r"^CREATE\s+DATABASE\s+.+?;$", re.IGNORECASE | re.MULTILINE)
CONNECT_RE = re.compile(r"^\\connect\\s+.+?$", re.IGNORECASE | re.MULTILINE)
TABLE_NAME_MAP = {
"ModelType": "model_types",
"TypeMachine": "type_machines",
"TypeMachineComponentRequirement": "type_machine_component_requirements",
"TypeMachinePieceRequirement": "type_machine_piece_requirements",
"TypeMachineProductRequirement": "type_machine_product_requirements",
"MachinePieceLink": "machine_piece_links",
"MachineComponentLink": "machine_component_links",
"MachineProductLink": "machine_product_links",
"Machine": "machines",
"Product": "products",
"Piece": "pieces",
"Composant": "composants",
"Profile": "profiles",
"CustomField": "custom_fields",
"CustomFieldValue": "custom_field_values",
"Document": "documents",
"Constructeur": "constructeurs",
"Site": "sites",
}
SKIP_TABLES = {
"_prisma_migrations",
}
def to_snake(name: str) -> str:
out = []
length = len(name)
for i, ch in enumerate(name):
if ch.isupper():
prev = name[i - 1] if i > 0 else ""
nxt = name[i + 1] if i + 1 < length else ""
if i > 0 and (prev.islower() or prev.isdigit() or (prev.isupper() and nxt.islower())):
out.append("_")
out.append(ch.lower())
else:
out.append(ch)
return "".join(out)
def to_lower_compact(name: str) -> str:
return name.replace("_", "").lower()
def remap_table(ident: str, mode: str) -> str:
mapped = TABLE_NAME_MAP.get(ident)
if mapped is not None:
return mapped
if mode == "snake":
return to_snake(ident)
if mode == "lower":
return to_lower_compact(ident)
raise ValueError(f"Unsupported mode: {mode}")
def extract_table_ident(prefix: str) -> str | None:
match = TABLE_RE.search(prefix)
if not match:
return None
table = match.group("table")
# Handle quoted schema like "public"."TableName"
if '"."' in table:
parts = table.split('"."', 1)
ident = parts[1].strip('"')
elif "." in table:
_, ident = table.split(".", 1)
ident = ident.strip('"')
else:
ident = table.strip('"')
return ident
def normalize_table_name(prefix: str, mode: str) -> str:
def repl(match: re.Match[str]) -> str:
table = match.group("table")
schema = ""
ident = table
# Handle quoted schema like "public"."TableName"
if '"."' in table:
parts = table.split('"."', 1)
schema_name = parts[0].strip('"')
ident = parts[1].strip('"')
schema = f'"{schema_name}".'
elif "." in table:
schema_part, ident = table.split(".", 1)
schema_name = schema_part.strip('"')
schema = f'"{schema_name}".'
ident = ident.strip('"')
else:
ident = table.strip('"')
mapped = remap_table(ident, mode)
return f'{match.group("before")}{schema}"{mapped}"'
return TABLE_RE.sub(repl, prefix)
def remap_columns(cols: str, mode: str) -> str:
def repl(match: re.Match[str]) -> str:
name = match.group(1)
if mode == "snake":
if any(ch.isupper() for ch in name):
return f"\"{to_snake(name)}\""
return match.group(0)
if mode == "lower":
return f"\"{to_lower_compact(name)}\""
raise ValueError(f"Unsupported mode: {mode}")
return re.sub(r"\"([^\"]+)\"", repl, cols)
def normalize_dump(sql: str, mode: str) -> str:
sql = CREATE_DB_RE.sub("", sql)
sql = CONNECT_RE.sub("", sql)
def repl(match: re.Match[str]) -> str:
raw_prefix = match.group("prefix")
ident = extract_table_ident(raw_prefix)
if ident is not None:
mapped = remap_table(ident, mode)
if ident in SKIP_TABLES or mapped in SKIP_TABLES:
return ""
prefix = normalize_table_name(raw_prefix, mode)
cols = remap_columns(match.group("cols"), mode)
return f"{prefix}{cols}{match.group('suffix')}"
return INSERT_RE.sub(repl, sql)
def main() -> int:
if len(sys.argv) not in (3, 4):
print("Usage: scripts/normalize-dump.py <input.sql> <output.sql> [--snake|--lower]", file=sys.stderr)
return 1
src = sys.argv[1]
dst = sys.argv[2]
mode = "lower"
if len(sys.argv) == 4:
if sys.argv[3] == "--snake":
mode = "snake"
elif sys.argv[3] == "--lower":
mode = "lower"
else:
print("Invalid mode. Use --snake or --lower.", file=sys.stderr)
return 1
with open(src, "r", encoding="utf-8") as f:
data = f.read()
normalized = normalize_dump(data, mode)
with open(dst, "w", encoding="utf-8") as f:
f.write(normalized)
return 0
if __name__ == "__main__":
raise SystemExit(main())

195
scripts/release.sh Executable file
View File

@@ -0,0 +1,195 @@
#!/bin/bash
set -e
# Couleurs pour l'affichage
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Répertoire racine du projet
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
VERSION_FILE="$PROJECT_ROOT/VERSION"
API_PLATFORM_FILE="$PROJECT_ROOT/config/packages/api_platform.yaml"
FRONTEND_DIR="$PROJECT_ROOT/Inventory_frontend"
# Lire la version actuelle
current_version=$(cat "$VERSION_FILE" | tr -d '\n')
# Fonction pour afficher l'aide
show_help() {
echo -e "${BLUE}Usage:${NC} $0 [version|bump_type]"
echo ""
echo "Arguments:"
echo " version Version spécifique (ex: 1.2.3)"
echo " bump_type Type de bump: major, minor, patch"
echo ""
echo "Exemples:"
echo " $0 1.0.0 # Définit la version à 1.0.0"
echo " $0 patch # 1.0.0 -> 1.0.1"
echo " $0 minor # 1.0.0 -> 1.1.0"
echo " $0 major # 1.0.0 -> 2.0.0"
echo ""
echo -e "Version actuelle: ${GREEN}$current_version${NC}"
}
# Fonction pour bumper la version
bump_version() {
local version=$1
local bump_type=$2
IFS='.' read -r major minor patch <<< "$version"
case $bump_type in
major)
major=$((major + 1))
minor=0
patch=0
;;
minor)
minor=$((minor + 1))
patch=0
;;
patch)
patch=$((patch + 1))
;;
esac
echo "$major.$minor.$patch"
}
# Vérifier les arguments
if [ $# -eq 0 ]; then
show_help
exit 0
fi
arg=$1
# Déterminer la nouvelle version
case $arg in
major|minor|patch)
new_version=$(bump_version "$current_version" "$arg")
;;
-h|--help|help)
show_help
exit 0
;;
*)
# Vérifier le format de version (semver basique)
if [[ $arg =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
new_version=$arg
else
echo -e "${RED}Erreur:${NC} Format de version invalide: $arg"
echo "Utilisez le format X.Y.Z (ex: 1.2.3) ou major/minor/patch"
exit 1
fi
;;
esac
echo -e "${BLUE}Release v$new_version${NC}"
echo "================================"
echo -e "Version actuelle: ${YELLOW}$current_version${NC}"
echo -e "Nouvelle version: ${GREEN}$new_version${NC}"
echo ""
# Vérifier qu'on n'est pas sur une version identique
if [ "$current_version" = "$new_version" ]; then
echo -e "${YELLOW}La version est déjà $new_version${NC}"
exit 0
fi
# Demander confirmation
read -p "Continuer ? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Annulé."
exit 0
fi
cd "$PROJECT_ROOT"
# ===========================================
# ÉTAPE 1 : Gérer le submodule frontend
# ===========================================
echo ""
echo -e "${BLUE}[1/6]${NC} Vérification du submodule frontend..."
cd "$FRONTEND_DIR"
# Vérifier s'il y a des changements non commités dans le submodule
if ! git diff --quiet --exit-code || ! git diff --cached --quiet --exit-code; then
echo -e "${YELLOW}Changements détectés dans le submodule frontend${NC}"
git status --short
echo ""
read -p "Commiter ces changements dans le submodule ? (y/N) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
git add -A
git commit -m "chore(release) : prepare v$new_version"
else
echo -e "${RED}Erreur:${NC} Veuillez d'abord commiter les changements du submodule."
exit 1
fi
fi
# ===========================================
# ÉTAPE 2 : Tag le submodule
# ===========================================
echo -e "${BLUE}[2/6]${NC} Création du tag v$new_version dans le submodule..."
# Vérifier si le tag existe déjà
if git rev-parse "v$new_version" >/dev/null 2>&1; then
echo -e "${YELLOW}Le tag v$new_version existe déjà dans le submodule${NC}"
else
git tag -a "v$new_version" -m "Release v$new_version"
echo -e "${GREEN}Tag v$new_version créé dans le submodule${NC}"
fi
# Retourner au projet principal
cd "$PROJECT_ROOT"
# ===========================================
# ÉTAPE 3 : Mettre à jour VERSION
# ===========================================
echo -e "${BLUE}[3/6]${NC} Mise à jour du fichier VERSION..."
echo "$new_version" > "$VERSION_FILE"
# ===========================================
# ÉTAPE 4 : Mettre à jour api_platform.yaml
# ===========================================
echo -e "${BLUE}[4/6]${NC} Mise à jour de api_platform.yaml..."
sed -i "s/version: .*/version: $new_version/" "$API_PLATFORM_FILE"
# ===========================================
# ÉTAPE 5 : Commit principal (avec mise à jour du submodule)
# ===========================================
echo -e "${BLUE}[5/6]${NC} Création du commit principal..."
git add "$VERSION_FILE" "$API_PLATFORM_FILE" "$FRONTEND_DIR"
git commit -m "chore(release) : v$new_version"
# ===========================================
# ÉTAPE 6 : Tag principal
# ===========================================
echo -e "${BLUE}[6/6]${NC} Création du tag v$new_version..."
git tag -a "v$new_version" -m "Release v$new_version"
echo ""
echo -e "${GREEN}✓ Release v$new_version préparée avec succès !${NC}"
echo ""
echo "================================"
echo -e "${YELLOW}Prochaines étapes :${NC}"
echo ""
echo "1. Pousser le submodule frontend :"
echo -e " ${BLUE}cd Inventory_frontend && git push && git push --tags && cd ..${NC}"
echo ""
echo "2. Pousser le projet principal :"
echo -e " ${BLUE}git push && git push --tags${NC}"
echo ""
echo "3. Créer les releases sur Gitea :"
echo " - Inventory_frontend : tag v$new_version"
echo " - Inventory (backend) : tag v$new_version"
echo ""
echo "================================"

View File

@@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
function parseDatabaseUrl(string $url): array
{
$parts = parse_url($url);
if ($parts === false) {
throw new RuntimeException('Invalid database URL.');
}
$host = $parts['host'] ?? 'localhost';
$port = isset($parts['port']) ? (int) $parts['port'] : 5432;
$user = $parts['user'] ?? '';
$pass = $parts['pass'] ?? '';
$db = ltrim($parts['path'] ?? '', '/');
return [
'dsn' => sprintf('pgsql:host=%s;port=%d;dbname=%s', $host, $port, $db),
'user' => $user,
'pass' => $pass,
];
}
function connect(string $url): PDO
{
$config = parseDatabaseUrl($url);
$pdo = new PDO($config['dsn'], $config['user'], $config['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
return $pdo;
}
$sourceUrl = getenv('SOURCE_DATABASE_URL') ?: '';
$targetUrl = getenv('TARGET_DATABASE_URL') ?: '';
if ($sourceUrl === '' || $targetUrl === '') {
fwrite(STDERR, "Usage: SOURCE_DATABASE_URL=... TARGET_DATABASE_URL=... php scripts/validate-migration.php\n");
exit(1);
}
$tables = [
'sites',
'type_machines',
'machines',
'model_types',
'composants',
'pieces',
'products',
'constructeurs',
'documents',
'custom_fields',
'custom_field_values',
'machine_component_links',
'machine_piece_links',
'machine_product_links',
'type_machine_component_requirements',
'type_machine_piece_requirements',
'type_machine_product_requirements',
'_machineconstructeurs',
'_composantconstructeurs',
'_piececonstructeurs',
'_productconstructeurs',
'profiles',
];
$skipTables = array_filter(array_map('trim', explode(',', getenv('SKIP_TABLES') ?: 'profiles')));
$sourceTableMap = [
'model_types' => ['ModelType', 'model_types'],
'_machineconstructeurs' => ['_MachineConstructeurs', '_machineconstructeurs'],
'_composantconstructeurs' => ['_ComposantConstructeurs', '_composantconstructeurs'],
'_piececonstructeurs' => ['_PieceConstructeurs', '_piececonstructeurs'],
'_productconstructeurs' => ['_ProductConstructeurs', '_productconstructeurs'],
];
function resolveTable(PDO $db, array $candidates): ?string
{
foreach ($candidates as $candidate) {
$exists = (bool) $db
->query(sprintf("SELECT to_regclass('public.%s')", $candidate))
->fetchColumn();
if ($exists) {
return $candidate;
}
$quoted = (bool) $db
->query(sprintf("SELECT to_regclass('public.\"%s\"')", $candidate))
->fetchColumn();
if ($quoted) {
return sprintf('"%s"', $candidate);
}
}
return null;
}
$source = connect($sourceUrl);
$target = connect($targetUrl);
$hasDifferences = false;
foreach ($tables as $table) {
if (in_array($table, $skipTables, true)) {
continue;
}
$sourceCandidates = $sourceTableMap[$table] ?? [$table];
$sourceTable = resolveTable($source, $sourceCandidates);
$sourceExists = $sourceTable !== null;
$targetExists = (bool) $target
->query(sprintf("SELECT to_regclass('public.%s')", $table))
->fetchColumn();
if (!$sourceExists || !$targetExists) {
$hasDifferences = true;
printf(
"%s: source=%s target=%s\n",
$table,
$sourceExists ? 'exists' : 'missing',
$targetExists ? 'exists' : 'missing'
);
continue;
}
$sourceCount = (int) $source->query(sprintf('SELECT COUNT(*) FROM public.%s', $sourceTable))->fetchColumn();
$targetCount = (int) $target->query(sprintf('SELECT COUNT(*) FROM public.%s', $table))->fetchColumn();
if ($sourceCount !== $targetCount) {
$hasDifferences = true;
printf("%s: source=%d target=%d\n", $table, $sourceCount, $targetCount);
}
}
if ($hasDifferences) {
exit(2);
}
echo "Counts match for all tables.\n";

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Document;
use App\Repository\DocumentRepository;
use App\Service\DocumentStorageService;
use App\Service\PdfCompressorService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
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 function count;
use function strlen;
#[AsCommand(
name: 'app:compress-pdf',
description: 'Compress all PDF documents without quality loss',
)]
class CompressPdfCommand extends Command
{
public function __construct(
private readonly DocumentRepository $documentRepository,
private readonly EntityManagerInterface $em,
private readonly PdfCompressorService $pdfCompressor,
private readonly DocumentStorageService $storageService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be compressed without actually doing it')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = $input->getOption('dry-run');
// Check if qpdf is installed
exec('which qpdf', $qpdfPath, $returnCode);
if (0 !== $returnCode) {
$io->error('qpdf is not installed. Run: sudo apt install qpdf');
return Command::FAILURE;
}
$documents = $this->documentRepository->findBy(['mimeType' => 'application/pdf']);
if (empty($documents)) {
$io->info('No PDF documents found.');
return Command::SUCCESS;
}
$io->title('PDF Compression');
$io->text(sprintf('Found %d PDF documents', count($documents)));
$totalSaved = 0;
$compressed = 0;
foreach ($documents as $document) {
$path = $document->getPath();
if ($this->storageService->isBase64DataUri($path)) {
$this->compressBase64Document($document, $path, $dryRun, $io, $totalSaved, $compressed);
} else {
$this->compressFileDocument($document, $path, $dryRun, $io, $totalSaved, $compressed);
}
}
if (!$dryRun && $compressed > 0) {
$this->em->flush();
$io->success(sprintf(
'Compressed %d/%d PDFs. Total space saved: %s',
$compressed,
count($documents),
$this->formatBytes($totalSaved)
));
} elseif ($dryRun) {
$io->info('Dry run completed. No changes made.');
} else {
$io->info('No PDFs needed compression.');
}
return Command::SUCCESS;
}
private function compressBase64Document(
Document $document,
string $path,
bool $dryRun,
SymfonyStyle $io,
int &$totalSaved,
int &$compressed,
): void {
$base64Data = $path;
if (str_contains($base64Data, ',')) {
$base64Data = explode(',', $base64Data, 2)[1];
}
$pdfContent = base64_decode($base64Data, true);
if (false === $pdfContent) {
$io->warning(sprintf('Failed to decode document: %s', $document->getName()));
return;
}
$originalSize = strlen($pdfContent);
if ($dryRun) {
$io->text(sprintf(
' [DRY-RUN] Would compress (base64): %s (%s)',
$document->getName(),
$this->formatBytes($originalSize)
));
return;
}
$result = $this->pdfCompressor->compressBase64Pdf($path);
if (null !== $result) {
$document->setPath($result['path']);
$document->setSize($result['size']);
$totalSaved += $result['saved'];
++$compressed;
$io->text(sprintf(
' OK %s: %s → %s (-%s, -%.1f%%)',
$document->getName(),
$this->formatBytes($result['originalSize']),
$this->formatBytes($result['size']),
$this->formatBytes($result['saved']),
($result['saved'] / $result['originalSize']) * 100
));
} else {
$io->text(sprintf(
' - %s: Already optimal (%s)',
$document->getName(),
$this->formatBytes($originalSize)
));
}
}
private function compressFileDocument(
Document $document,
string $path,
bool $dryRun,
SymfonyStyle $io,
int &$totalSaved,
int &$compressed,
): void {
$absolutePath = $this->storageService->getAbsolutePath($path);
if (!file_exists($absolutePath)) {
$io->warning(sprintf('File not found: %s (%s)', $document->getName(), $path));
return;
}
$originalSize = filesize($absolutePath);
if (false === $originalSize) {
return;
}
if ($dryRun) {
$io->text(sprintf(
' [DRY-RUN] Would compress (file): %s (%s)',
$document->getName(),
$this->formatBytes($originalSize)
));
return;
}
$result = $this->pdfCompressor->compressFile($absolutePath);
if (null !== $result) {
$document->setSize($result['size']);
$totalSaved += $result['saved'];
++$compressed;
$io->text(sprintf(
' OK %s: %s → %s (-%s, -%.1f%%)',
$document->getName(),
$this->formatBytes($result['originalSize']),
$this->formatBytes($result['size']),
$this->formatBytes($result['saved']),
($result['saved'] / $result['originalSize']) * 100
));
} else {
$io->text(sprintf(
' - %s: Already optimal (%s)',
$document->getName(),
$this->formatBytes($originalSize)
));
}
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
++$i;
}
return round($bytes, 2).' '.$units[$i];
}
}

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,85 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\ProfileRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use function count;
use function in_array;
#[AsCommand(
name: 'app:init-profile-passwords',
description: 'Initialize all profile passwords to first letter of firstName + "123"',
)]
class InitProfilePasswordsCommand extends Command
{
public function __construct(
private readonly ProfileRepository $profiles,
private readonly EntityManagerInterface $em,
private readonly UserPasswordHasherInterface $passwordHasher,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$all = $this->profiles->findAll();
if (0 === count($all)) {
$io->warning('Aucun profil trouvé.');
return Command::SUCCESS;
}
// Promote first profile to ROLE_ADMIN if none exists
$hasAdmin = false;
foreach ($all as $profile) {
if (in_array('ROLE_ADMIN', $profile->getRoles(), true)) {
$hasAdmin = true;
break;
}
}
$isFirst = true;
$count = 0;
foreach ($all as $profile) {
// Set password: first letter of firstName + "123"
$firstLetter = mb_strtoupper(mb_substr($profile->getFirstName(), 0, 1));
$plain = $firstLetter.'123';
$hashed = $this->passwordHasher->hashPassword($profile, $plain);
$profile->setPassword($hashed);
// Set roles: first profile → ADMIN, others → VIEWER (minimum to use the app)
if (!$hasAdmin && $isFirst) {
$profile->setRoles(['ROLE_ADMIN']);
$io->writeln(sprintf(' %s %s → mdp: %s — ROLE_ADMIN', $profile->getFirstName(), $profile->getLastName(), $plain));
$isFirst = false;
} elseif (in_array('ROLE_USER', $profile->getRoles(), true) && !in_array('ROLE_VIEWER', $profile->getRoles(), true) && !in_array('ROLE_GESTIONNAIRE', $profile->getRoles(), true) && !in_array('ROLE_ADMIN', $profile->getRoles(), true)) {
$profile->setRoles(['ROLE_VIEWER']);
$io->writeln(sprintf(' %s %s → mdp: %s — ROLE_VIEWER', $profile->getFirstName(), $profile->getLastName(), $plain));
} else {
$io->writeln(sprintf(' %s %s → mdp: %s — %s', $profile->getFirstName(), $profile->getLastName(), $plain, implode(', ', $profile->getRoles())));
}
++$count;
}
$this->em->flush();
$io->success(sprintf('%d mot(s) de passe initialisé(s).', $count));
return Command::SUCCESS;
}
}

View File

@@ -0,0 +1,218 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Repository\DocumentRepository;
use App\Service\DocumentStorageService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
use function count;
use function strlen;
#[AsCommand(
name: 'app:migrate-documents-to-filesystem',
description: 'Migrate document storage from Base64 in DB to filesystem',
)]
class MigrateDocumentsToFilesystemCommand extends Command
{
public function __construct(
private readonly DocumentRepository $documentRepository,
private readonly EntityManagerInterface $em,
private readonly DocumentStorageService $storageService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show what would be migrated without making changes')
->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Number of documents to process before flushing', '50')
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Max documents to migrate (for testing)', '0')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = $input->getOption('dry-run');
$batchSize = (int) $input->getOption('batch-size');
$limit = (int) $input->getOption('limit');
$io->title('Document Storage Migration: Base64 → Filesystem');
// Verify storage directory is writable
$storageDir = $this->storageService->getStorageDir();
if (!$dryRun) {
if (!is_dir($storageDir)) {
mkdir($storageDir, 0o775, true);
}
if (!is_writable($storageDir)) {
$io->error("Storage directory is not writable: {$storageDir}");
return Command::FAILURE;
}
$io->text("Storage directory: {$storageDir}");
}
// Step 1: fetch only IDs of Base64 documents (no heavy path column loaded)
$conn = $this->em->getConnection();
$ids = $conn->fetchFirstColumn("SELECT id FROM documents WHERE path LIKE 'data:%'");
$total = count($ids);
$migrated = 0;
$skipped = 0;
$errors = 0;
$totalBytes = 0;
$io->text(sprintf('Found %d documents with Base64 data to migrate', $total));
if (0 === $total) {
$io->success('Nothing to migrate — all documents are already file-based.');
return Command::SUCCESS;
}
// Step 2: process one document at a time to avoid memory exhaustion
foreach ($ids as $index => $docId) {
if ($limit > 0 && $migrated >= $limit) {
$io->text("Reached limit of {$limit} documents.");
break;
}
// Fetch single row with raw SQL to keep memory flat
$row = $conn->fetchAssociative(
'SELECT id, name, filename, path, mimetype, size FROM documents WHERE id = ?',
[$docId]
);
if (!$row) {
++$skipped;
continue;
}
$path = $row['path'];
if (!$this->storageService->isBase64DataUri($path)) {
++$skipped;
continue;
}
$docName = $row['name'] ?: $row['filename'];
$filename = $row['filename'] ?: $row['name'];
$mimeType = $row['mimetype'] ?? 'application/octet-stream';
// Extract binary content from data URI
$parts = explode(',', $path, 2);
$base64 = $parts[1] ?? '';
$content = base64_decode($base64, true);
// Free the raw row immediately
unset($row, $path, $base64, $parts);
if (false === $content || '' === $content) {
$io->warning(sprintf('[%d/%d] Cannot decode: %s (id: %s)', $index + 1, $total, $docName, $docId));
++$errors;
continue;
}
$fileSize = strlen($content);
$extension = $this->storageService->extensionFromFilename(
$filename ?: ('file.'.$this->storageService->extensionFromMimeType($mimeType))
);
if ($dryRun) {
$io->text(sprintf(
' [DRY-RUN] Would migrate: %s (%s)',
$docName,
$this->formatBytes($fileSize)
));
++$migrated;
$totalBytes += $fileSize;
unset($content);
continue;
}
try {
$relativePath = $this->storageService->store($content, $docId, $extension);
unset($content);
// Update DB directly — avoid loading entity with huge path
$conn->executeStatement(
'UPDATE documents SET path = ?, size = ? WHERE id = ?',
[$relativePath, $fileSize, $docId]
);
++$migrated;
$totalBytes += $fileSize;
$io->text(sprintf(
' [OK] %s → %s (%s)',
$docName,
$relativePath,
$this->formatBytes($fileSize)
));
} catch (Throwable $e) {
unset($content);
$io->error(sprintf(
' [FAIL] %s: %s',
$docName,
$e->getMessage()
));
++$errors;
continue;
}
if (0 === $migrated % $batchSize) {
$io->text(sprintf(' ... %d migrated so far', $migrated));
}
}
$io->newLine();
$io->table(
['Metric', 'Count'],
[
['Total documents', (string) $total],
['Migrated', (string) $migrated],
['Skipped (already file-based)', (string) $skipped],
['Errors', (string) $errors],
['Total bytes written', $this->formatBytes($totalBytes)],
]
);
if ($dryRun) {
$io->info('Dry run completed. No changes were made.');
} elseif ($errors > 0) {
$io->warning(sprintf('Migration completed with %d errors.', $errors));
} else {
$io->success('Migration completed successfully.');
}
return $errors > 0 ? Command::FAILURE : Command::SUCCESS;
}
private function formatBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
++$i;
}
return round($bytes, 2).' '.$units[$i];
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class ActivityLogController extends AbstractController
{
public function __construct(
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {}
#[Route('/api/activity-logs', name: 'api_activity_logs', methods: ['GET'])]
public function __invoke(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$page = max(1, $request->query->getInt('page', 1));
$itemsPerPage = min(100, max(1, $request->query->getInt('itemsPerPage', 30)));
$filters = [];
if ($entityType = $request->query->get('entityType')) {
$filters['entityType'] = $entityType;
}
if ($action = $request->query->get('action')) {
$filters['action'] = $action;
}
$result = $this->auditLogs->findAllPaginated($page, $itemsPerPage, $filters);
$actorIds = array_values(array_unique(array_filter(array_map(
static fn ($log) => $log->getActorProfileId(),
$result['items'],
))));
$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();
$snapshot = $log->getSnapshot();
return [
'id' => $log->getId(),
'entityType' => $log->getEntityType(),
'entityId' => $log->getEntityId(),
'entityName' => $snapshot['name'] ?? null,
'entityRef' => $snapshot['reference'] ?? null,
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId
? [
'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId,
]
: null,
'diff' => $log->getDiff(),
'snapshot' => $snapshot,
];
},
$result['items'],
);
return new JsonResponse([
'items' => array_values($items),
'total' => $result['total'],
'page' => $page,
'itemsPerPage' => $itemsPerPage,
]);
}
}

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Profile;
use App\Repository\ProfileRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use function count;
use function in_array;
#[Route('/api/admin/profiles')]
final class AdminProfileController extends AbstractController
{
public function __construct(
private readonly ProfileRepository $profiles,
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
#[Route('', name: 'admin_profiles_list', methods: ['GET'])]
public function list(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$items = $this->profiles->findBy([], ['firstName' => 'ASC']);
return new JsonResponse(array_map([$this, 'serializeProfile'], $items));
}
#[Route('', name: 'admin_profiles_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$payload = $request->toArray();
$firstName = trim((string) ($payload['firstName'] ?? ''));
$lastName = trim((string) ($payload['lastName'] ?? ''));
if ('' === $firstName || '' === $lastName) {
return new JsonResponse(['message' => 'firstName et lastName sont requis.'], JsonResponse::HTTP_BAD_REQUEST);
}
$email = trim((string) ($payload['email'] ?? ''));
$password = $payload['password'] ?? null;
$role = $payload['role'] ?? 'ROLE_VIEWER';
$allowedRoles = ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'];
if (!in_array($role, $allowedRoles, true)) {
return new JsonResponse(['message' => 'Role invalide.'], JsonResponse::HTTP_BAD_REQUEST);
}
$profile = new Profile();
$profile->setFirstName($firstName);
$profile->setLastName($lastName);
$profile->setIsActive(true);
$profile->setRoles([$role]);
if ('' !== $email) {
$profile->setEmail($email);
}
if (null !== $password && '' !== $password) {
$profile->setPassword(
$this->passwordHasher->hashPassword($profile, $password)
);
}
$this->entityManager->persist($profile);
$this->entityManager->flush();
return new JsonResponse($this->serializeProfile($profile), JsonResponse::HTTP_CREATED);
}
#[Route('/{id}/role', name: 'admin_profiles_update_role', methods: ['PUT'])]
public function updateRole(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$profile = $this->profiles->find($id);
if (!$profile) {
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
}
$payload = $request->toArray();
$role = $payload['role'] ?? null;
$allowedRoles = ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'];
if (!$role || !in_array($role, $allowedRoles, true)) {
return new JsonResponse(['message' => 'Role invalide.'], JsonResponse::HTTP_BAD_REQUEST);
}
// Prevent removing the last admin
if (in_array('ROLE_ADMIN', $profile->getRoles(), true) && 'ROLE_ADMIN' !== $role) {
$adminCount = $this->countAdmins();
if ($adminCount <= 1) {
return new JsonResponse(
['message' => 'Impossible de retirer le dernier administrateur.'],
JsonResponse::HTTP_CONFLICT
);
}
}
$profile->setRoles([$role]);
$this->entityManager->flush();
return new JsonResponse($this->serializeProfile($profile));
}
#[Route('/{id}/password', name: 'admin_profiles_update_password', methods: ['PUT'])]
public function updatePassword(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$profile = $this->profiles->find($id);
if (!$profile) {
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
}
$payload = $request->toArray();
$password = $payload['password'] ?? '';
if ('' === $password) {
return new JsonResponse(['message' => 'Le mot de passe est requis.'], JsonResponse::HTTP_BAD_REQUEST);
}
$profile->setPassword(
$this->passwordHasher->hashPassword($profile, $password)
);
$this->entityManager->flush();
return new JsonResponse($this->serializeProfile($profile));
}
#[Route('/{id}/deactivate', name: 'admin_profiles_deactivate', methods: ['PUT'])]
public function deactivate(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
$profile = $this->profiles->find($id);
if (!$profile) {
return new JsonResponse(['message' => 'Profil introuvable.'], JsonResponse::HTTP_NOT_FOUND);
}
// Prevent deactivating the last admin
if (in_array('ROLE_ADMIN', $profile->getRoles(), true)) {
$adminCount = $this->countAdmins();
if ($adminCount <= 1) {
return new JsonResponse(
['message' => 'Impossible de desactiver le dernier administrateur.'],
JsonResponse::HTTP_CONFLICT
);
}
}
$profile->setIsActive(false);
$this->entityManager->flush();
return new JsonResponse($this->serializeProfile($profile));
}
private function serializeProfile(Profile $profile): array
{
return [
'id' => $profile->getId(),
'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(),
'email' => $profile->getEmail(),
'isActive' => $profile->isActive(),
'hasPassword' => null !== $profile->getPassword() && '' !== $profile->getPassword(),
'roles' => $profile->getRoles(),
'createdAt' => $profile->getCreatedAt()->format('c'),
'updatedAt' => $profile->getUpdatedAt()->format('c'),
];
}
private function countAdmins(): int
{
$all = $this->profiles->findBy(['isActive' => true]);
return count(array_filter(
$all,
static fn (Profile $p) => in_array('ROLE_ADMIN', $p->getRoles(), true)
));
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Comment;
use App\Repository\ProfileRepository;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/comments')]
final class CommentController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ProfileRepository $profiles,
) {}
#[Route('', name: 'api_comments_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$session = $request->getSession();
$profileId = $session->get('profileId');
if (!$profileId) {
return $this->json(['message' => 'Aucun profil actif.'], 401);
}
$profile = $this->profiles->find($profileId);
if (!$profile) {
return $this->json(['message' => 'Profil introuvable.'], 401);
}
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
return $this->json(['message' => 'Payload JSON invalide.'], 400);
}
$content = trim((string) ($payload['content'] ?? ''));
$entityType = trim((string) ($payload['entityType'] ?? ''));
$entityId = trim((string) ($payload['entityId'] ?? ''));
$entityName = isset($payload['entityName']) ? trim((string) $payload['entityName']) : null;
if ('' === $content) {
return $this->json(['message' => 'Le contenu est requis.'], 400);
}
$allowedTypes = ['machine', 'piece', 'composant', 'product', 'piece_category', 'component_category', 'product_category', 'machine_skeleton'];
if (!in_array($entityType, $allowedTypes, true)) {
return $this->json(['message' => 'Type d\'entité invalide.'], 400);
}
if ('' === $entityId) {
return $this->json(['message' => 'L\'identifiant de l\'entité est requis.'], 400);
}
$authorName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ('' === $authorName) {
$authorName = $profile->getEmail() ?? 'Inconnu';
}
$comment = new Comment();
$comment->setContent($content);
$comment->setEntityType($entityType);
$comment->setEntityId($entityId);
$comment->setEntityName($entityName);
$comment->setAuthorId($profileId);
$comment->setAuthorName($authorName);
$this->entityManager->persist($comment);
$this->entityManager->flush();
return $this->json($this->normalize($comment), 201);
}
#[Route('/{id}/resolve', name: 'api_comments_resolve', methods: ['PATCH'])]
public function resolve(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$comment = $this->entityManager->getRepository(Comment::class)->find($id);
if (!$comment) {
return $this->json(['message' => 'Commentaire introuvable.'], 404);
}
$session = $request->getSession();
$profileId = $session->get('profileId');
$profile = $profileId ? $this->profiles->find($profileId) : null;
$resolverName = 'Inconnu';
if ($profile) {
$resolverName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ('' === $resolverName) {
$resolverName = $profile->getEmail() ?? 'Inconnu';
}
}
$comment->setStatus('resolved');
$comment->setResolvedById($profileId);
$comment->setResolvedByName($resolverName);
$comment->setResolvedAt(new DateTimeImmutable());
$this->entityManager->flush();
return $this->json($this->normalize($comment));
}
#[Route('/stats/unresolved-count', name: 'api_comments_unresolved_count', methods: ['GET'])]
public function unresolvedCount(): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$count = $this->entityManager->getRepository(Comment::class)
->count(['status' => 'open'])
;
return $this->json(['count' => $count]);
}
private function normalize(Comment $comment): array
{
return [
'id' => $comment->getId(),
'content' => $comment->getContent(),
'entityType' => $comment->getEntityType(),
'entityId' => $comment->getEntityId(),
'entityName' => $comment->getEntityName(),
'authorId' => $comment->getAuthorId(),
'authorName' => $comment->getAuthorName(),
'status' => $comment->getStatus(),
'resolvedById' => $comment->getResolvedById(),
'resolvedByName' => $comment->getResolvedByName(),
'resolvedAt' => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM),
'createdAt' => $comment->getCreatedAt()->format(DateTimeInterface::ATOM),
'updatedAt' => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM),
];
}
}

View File

@@ -0,0 +1,304 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Repository\ComposantRepository;
use App\Repository\CustomFieldRepository;
use App\Repository\CustomFieldValueRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
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/custom-fields/values')]
class CustomFieldValueController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly CustomFieldRepository $customFieldRepository,
private readonly CustomFieldValueRepository $customFieldValueRepository,
private readonly MachineRepository $machineRepository,
private readonly ComposantRepository $composantRepository,
private readonly PieceRepository $pieceRepository,
private readonly ProductRepository $productRepository,
) {}
#[Route('', name: 'custom_field_values_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$payload = $this->decodePayload($request);
if ($payload instanceof JsonResponse) {
return $payload;
}
$customField = $this->resolveCustomField($payload);
if ($customField instanceof JsonResponse) {
return $customField;
}
$target = $this->resolveTarget($payload);
if ($target instanceof JsonResponse) {
return $target;
}
$value = new CustomFieldValue();
$value->setCustomField($customField);
$value->setValue((string) ($payload['value'] ?? ''));
$this->applyTarget($value, $target['type'], $target['entity']);
$this->entityManager->persist($value);
$this->entityManager->flush();
return $this->json($this->normalizeCustomFieldValue($value));
}
#[Route('/upsert', name: 'custom_field_values_upsert', methods: ['POST'])]
public function upsert(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$payload = $this->decodePayload($request);
if ($payload instanceof JsonResponse) {
return $payload;
}
$customField = $this->resolveCustomField($payload);
if ($customField instanceof JsonResponse) {
return $customField;
}
$target = $this->resolveTarget($payload);
if ($target instanceof JsonResponse) {
return $target;
}
$existing = $this->customFieldValueRepository->findOneBy([
'customField' => $customField,
$target['type'] => $target['entity'],
]);
if ($existing instanceof CustomFieldValue) {
$existing->setValue((string) ($payload['value'] ?? ''));
$this->entityManager->flush();
return $this->json($this->normalizeCustomFieldValue($existing));
}
$value = new CustomFieldValue();
$value->setCustomField($customField);
$value->setValue((string) ($payload['value'] ?? ''));
$this->applyTarget($value, $target['type'], $target['entity']);
$this->entityManager->persist($value);
$this->entityManager->flush();
return $this->json($this->normalizeCustomFieldValue($value));
}
#[Route('/{entityType}/{entityId}', name: 'custom_field_values_list', methods: ['GET'])]
public function listByEntity(string $entityType, string $entityId): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$target = $this->resolveTarget([
'entityType' => $entityType,
'entityId' => $entityId,
]);
if ($target instanceof JsonResponse) {
return $target;
}
$values = $this->customFieldValueRepository->findBy([
$target['type'] => $target['entity'],
]);
return $this->json(array_map(
fn (CustomFieldValue $value) => $this->normalizeCustomFieldValue($value),
$values
));
}
#[Route('/{id}', name: 'custom_field_values_update', methods: ['PATCH'])]
public function update(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$value = $this->customFieldValueRepository->find($id);
if (!$value instanceof CustomFieldValue) {
return $this->json(['success' => false, 'error' => 'Custom field value not found.'], 404);
}
$payload = $this->decodePayload($request);
if ($payload instanceof JsonResponse) {
return $payload;
}
if (array_key_exists('value', $payload)) {
$value->setValue((string) $payload['value']);
}
$this->entityManager->flush();
return $this->json($this->normalizeCustomFieldValue($value));
}
#[Route('/{id}', name: 'custom_field_values_delete', methods: ['DELETE'])]
public function delete(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$value = $this->customFieldValueRepository->find($id);
if (!$value instanceof CustomFieldValue) {
return $this->json(['success' => false, 'error' => 'Custom field value not found.'], 404);
}
$this->entityManager->remove($value);
$this->entityManager->flush();
return $this->json(['success' => true]);
}
private function decodePayload(Request $request): array|JsonResponse
{
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
}
return $payload;
}
private function resolveCustomField(array $payload): CustomField|JsonResponse
{
$customFieldId = isset($payload['customFieldId']) ? trim((string) $payload['customFieldId']) : '';
if ('' !== $customFieldId) {
$customField = $this->customFieldRepository->find($customFieldId);
if ($customField instanceof CustomField) {
return $customField;
}
return $this->json(['success' => false, 'error' => 'Custom field not found.'], 404);
}
$customFieldName = isset($payload['customFieldName']) ? trim((string) $payload['customFieldName']) : '';
if ('' === $customFieldName) {
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
}
$customField = new CustomField();
$customField->setName($customFieldName);
$customField->setType((string) ($payload['customFieldType'] ?? 'text'));
$customField->setRequired((bool) ($payload['customFieldRequired'] ?? false));
$options = $payload['customFieldOptions'] ?? null;
if (is_array($options)) {
$customField->setOptions($options);
}
$this->entityManager->persist($customField);
return $customField;
}
private function resolveTarget(array $payload): array|JsonResponse
{
$entityType = isset($payload['entityType']) ? strtolower((string) $payload['entityType']) : '';
$entityId = isset($payload['entityId']) ? trim((string) $payload['entityId']) : '';
if ('' === $entityType || '' === $entityId) {
foreach (['machine', 'composant', 'piece', 'product'] as $candidate) {
$key = $candidate.'Id';
if (!isset($payload[$key])) {
continue;
}
$entityType = $candidate;
$entityId = trim((string) $payload[$key]);
break;
}
}
if ('' === $entityType || '' === $entityId) {
return $this->json(['success' => false, 'error' => 'Entity target is missing.'], 400);
}
return match ($entityType) {
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository),
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
};
}
private function resolveEntity(string $type, string $id, $repository): array|JsonResponse
{
$entity = $repository->find($id);
if (!$entity) {
return $this->json(['success' => false, 'error' => sprintf('%s not found.', $type)], 404);
}
return ['type' => $type, 'entity' => $entity];
}
private function applyTarget(CustomFieldValue $value, string $type, object $entity): void
{
switch ($type) {
case 'machine':
$value->setMachine($entity);
break;
case 'composant':
$value->setComposant($entity);
break;
case 'piece':
$value->setPiece($entity);
break;
case 'product':
$value->setProduct($entity);
break;
}
}
private function normalizeCustomFieldValue(CustomFieldValue $value): array
{
$customField = $value->getCustomField();
return [
'id' => $value->getId(),
'value' => $value->getValue(),
'customFieldId' => $customField->getId(),
'customField' => [
'id' => $customField->getId(),
'name' => $customField->getName(),
'type' => $customField->getType(),
'required' => $customField->isRequired(),
'options' => $customField->getOptions(),
'orderIndex' => $customField->getOrderIndex(),
],
'machineId' => $value->getMachine()?->getId(),
'composantId' => $value->getComposant()?->getId(),
'pieceId' => $value->getPiece()?->getId(),
'productId' => $value->getProduct()?->getId(),
'createdAt' => $value->getCreatedAt()->format(DATE_ATOM),
'updatedAt' => $value->getUpdatedAt()->format(DATE_ATOM),
];
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Document;
use App\Repository\ComposantRepository;
use App\Repository\DocumentRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use App\Repository\SiteRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/documents')]
class DocumentQueryController extends AbstractController
{
public function __construct(
private readonly DocumentRepository $documentRepository,
private readonly SiteRepository $siteRepository,
private readonly MachineRepository $machineRepository,
private readonly ComposantRepository $composantRepository,
private readonly PieceRepository $pieceRepository,
private readonly ProductRepository $productRepository,
) {}
#[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])]
public function listBySite(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$site = $this->siteRepository->find($id);
if (!$site) {
return $this->json(['success' => false, 'error' => 'Site not found.'], 404);
}
$documents = $this->documentRepository->findBy(['site' => $site]);
return $this->json($this->normalizeDocuments($documents));
}
#[Route('/machine/{id}', name: 'documents_by_machine', methods: ['GET'])]
public function listByMachine(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$machine = $this->machineRepository->find($id);
if (!$machine) {
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
}
$documents = $this->documentRepository->findBy(['machine' => $machine]);
return $this->json($this->normalizeDocuments($documents));
}
#[Route('/composant/{id}', name: 'documents_by_composant', methods: ['GET'])]
public function listByComposant(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$composant = $this->composantRepository->find($id);
if (!$composant) {
return $this->json(['success' => false, 'error' => 'Composant not found.'], 404);
}
$documents = $this->documentRepository->findBy(['composant' => $composant]);
return $this->json($this->normalizeDocuments($documents));
}
#[Route('/piece/{id}', name: 'documents_by_piece', methods: ['GET'])]
public function listByPiece(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$piece = $this->pieceRepository->find($id);
if (!$piece) {
return $this->json(['success' => false, 'error' => 'Piece not found.'], 404);
}
$documents = $this->documentRepository->findBy(['piece' => $piece]);
return $this->json($this->normalizeDocuments($documents));
}
#[Route('/product/{id}', name: 'documents_by_product', methods: ['GET'])]
public function listByProduct(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$product = $this->productRepository->find($id);
if (!$product) {
return $this->json(['success' => false, 'error' => 'Product not found.'], 404);
}
$documents = $this->documentRepository->findBy(['product' => $product]);
return $this->json($this->normalizeDocuments($documents));
}
/**
* @param Document[] $documents
*/
private function normalizeDocuments(array $documents): array
{
return array_map(static function (Document $document): array {
return [
'id' => $document->getId(),
'name' => $document->getName(),
'filename' => $document->getFilename(),
'fileUrl' => '/api/documents/'.$document->getId().'/file',
'downloadUrl' => '/api/documents/'.$document->getId().'/download',
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'siteId' => $document->getSite()?->getId(),
'machineId' => $document->getMachine()?->getId(),
'composantId' => $document->getComposant()?->getId(),
'pieceId' => $document->getPiece()?->getId(),
'productId' => $document->getProduct()?->getId(),
'createdAt' => $document->getCreatedAt()->format(DATE_ATOM),
'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM),
];
}, $documents);
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\DocumentRepository;
use App\Service\DocumentStorageService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use function strlen;
#[Route('/api/documents')]
class DocumentServeController extends AbstractController
{
public function __construct(
private readonly DocumentRepository $documentRepository,
private readonly DocumentStorageService $storageService,
) {}
#[Route('/{id}/file', name: 'document_serve_file', methods: ['GET'])]
public function serve(string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$document = $this->documentRepository->find($id);
if (!$document) {
return $this->json(['error' => 'Document not found.'], 404);
}
$path = $document->getPath();
// Backward compatibility: serve Base64 data URIs from DB
if ($this->storageService->isBase64DataUri($path)) {
$parts = explode(',', $path, 2);
$content = base64_decode($parts[1] ?? '', true);
if (false === $content) {
return $this->json(['error' => 'Invalid document data.'], 500);
}
return new Response($content, 200, [
'Content-Type' => $document->getMimeType(),
'Content-Disposition' => ResponseHeaderBag::DISPOSITION_INLINE.'; filename="'.$document->getFilename().'"',
'Content-Length' => (string) strlen($content),
'Cache-Control' => 'private, max-age=3600',
]);
}
// File-based path: serve from disk
$absolutePath = $this->storageService->getAbsolutePath($path);
if (!file_exists($absolutePath)) {
return $this->json(['error' => 'File not found on disk.'], 404);
}
$response = new BinaryFileResponse($absolutePath);
$response->headers->set('Content-Type', $document->getMimeType());
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$document->getFilename()
);
$response->headers->set('Cache-Control', 'private, max-age=3600');
return $response;
}
#[Route('/{id}/download', name: 'document_download_file', methods: ['GET'])]
public function download(string $id): Response
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$document = $this->documentRepository->find($id);
if (!$document) {
return $this->json(['error' => 'Document not found.'], 404);
}
$path = $document->getPath();
if ($this->storageService->isBase64DataUri($path)) {
$parts = explode(',', $path, 2);
$content = base64_decode($parts[1] ?? '', true);
if (false === $content) {
return $this->json(['error' => 'Invalid document data.'], 500);
}
return new Response($content, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => ResponseHeaderBag::DISPOSITION_ATTACHMENT.'; filename="'.$document->getFilename().'"',
'Content-Length' => (string) strlen($content),
]);
}
$absolutePath = $this->storageService->getAbsolutePath($path);
if (!file_exists($absolutePath)) {
return $this->json(['error' => 'File not found on disk.'], 404);
}
$response = new BinaryFileResponse($absolutePath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
$document->getFilename()
);
return $response;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
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 EntityHistoryController extends AbstractController
{
/** @var array<string, array{repo: EntityRepository<object>, label: string}> */
private readonly array $entityConfig;
public function __construct(
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 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');
$config = $this->entityConfig[$type];
$entity = $config['repo']->find($id);
if (!$entity) {
return new JsonResponse(
['message' => $config['label']],
Response::HTTP_NOT_FOUND,
);
}
$logs = $this->auditLogs->findEntityHistory($type, $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

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

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Repository\CustomFieldValueRepository;
use App\Repository\MachineRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/api/machines')]
class MachineCustomFieldsController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly MachineRepository $machineRepository,
private readonly CustomFieldValueRepository $customFieldValueRepository,
) {}
#[Route('/{id}/add-custom-fields', name: 'machine_add_custom_fields', methods: ['POST'])]
public function addMissingCustomFields(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$machine = $this->machineRepository->find($id);
if (!$machine instanceof Machine) {
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
}
foreach ($machine->getCustomFields() as $customField) {
if (!$customField instanceof CustomField) {
continue;
}
$existing = $this->customFieldValueRepository->findOneBy([
'machine' => $machine,
'customField' => $customField,
]);
if ($existing instanceof CustomFieldValue) {
continue;
}
$value = new CustomFieldValue();
$value->setMachine($machine);
$value->setCustomField($customField);
$value->setValue($customField->getDefaultValue() ?? '');
$this->entityManager->persist($value);
}
$this->entityManager->flush();
$values = $this->customFieldValueRepository->findBy(['machine' => $machine]);
return $this->json([
'success' => true,
'machineId' => $machine->getId(),
'customFieldValues' => array_map(
static fn (CustomFieldValue $value) => [
'id' => $value->getId(),
'value' => $value->getValue(),
'customFieldId' => $value->getCustomField()->getId(),
],
$values
),
]);
}
}

View File

@@ -0,0 +1,903 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Composant;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink;
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;
use App\Repository\MachineProductLinkRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use Doctrine\Common\Collections\Collection;
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/machines')]
class MachineStructureController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly MachineRepository $machineRepository,
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
private readonly MachineProductLinkRepository $machineProductLinkRepository,
private readonly ComposantRepository $composantRepository,
private readonly PieceRepository $pieceRepository,
private readonly ProductRepository $productRepository,
) {}
#[Route('/{id}/structure', name: 'machine_structure_get', methods: ['GET'])]
public function getStructure(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$machine = $this->machineRepository->find($id);
if (!$machine instanceof Machine) {
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
}
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine]);
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
return $this->json($this->normalizeStructureResponse(
$machine,
$componentLinks,
$pieceLinks,
$productLinks
));
}
#[Route('/{id}/structure', name: 'machine_structure_update', methods: ['PATCH'])]
public function updateStructure(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$machine = $this->machineRepository->find($id);
if (!$machine instanceof Machine) {
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
}
$payload = json_decode($request->getContent(), true);
if (!is_array($payload)) {
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
}
$componentLinksPayload = $this->normalizePayloadList($payload['componentLinks'] ?? []);
$pieceLinksPayload = $this->normalizePayloadList($payload['pieceLinks'] ?? []);
$productLinksPayload = $this->normalizePayloadList($payload['productLinks'] ?? []);
$componentLinks = $this->applyComponentLinks($machine, $componentLinksPayload);
if ($componentLinks instanceof JsonResponse) {
return $componentLinks;
}
$pieceLinks = $this->applyPieceLinks($machine, $pieceLinksPayload, $componentLinks);
if ($pieceLinks instanceof JsonResponse) {
return $pieceLinks;
}
$productLinks = $this->applyProductLinks($machine, $productLinksPayload, $componentLinks, $pieceLinks);
if ($productLinks instanceof JsonResponse) {
return $productLinks;
}
$this->entityManager->flush();
return $this->json($this->normalizeStructureResponse(
$machine,
$componentLinks,
$pieceLinks,
$productLinks
));
}
#[Route('/{id}/clone', name: 'machine_clone', methods: ['POST'])]
public function cloneMachine(string $id, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$source = $this->machineRepository->find($id);
if (!$source instanceof Machine) {
return $this->json(['success' => false, 'error' => 'Machine source introuvable.'], 404);
}
$payload = json_decode($request->getContent(), true);
if (!is_array($payload) || empty($payload['name']) || empty($payload['siteId'])) {
return $this->json(['success' => false, 'error' => 'name et siteId sont requis.'], 400);
}
$site = $this->entityManager->getRepository(Site::class)->find($payload['siteId']);
if (!$site) {
return $this->json(['success' => false, 'error' => 'Site introuvable.'], 404);
}
// Create new machine
$newMachine = new Machine();
$newMachine->setName($payload['name']);
$newMachine->setSite($site);
if (!empty($payload['reference'])) {
$newMachine->setReference($payload['reference']);
}
$newMachine->setPrix($source->getPrix());
// Copy constructeurs
foreach ($source->getConstructeurs() as $constructeur) {
$newMachine->getConstructeurs()->add($constructeur);
}
$this->entityManager->persist($newMachine);
// Copy custom fields and values
$this->cloneCustomFields($source, $newMachine);
// Copy component links (preserving hierarchy)
$componentLinkMap = $this->cloneComponentLinks($source, $newMachine);
// Copy piece links
$pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap);
// Copy product links
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
$this->entityManager->flush();
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine]);
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $newMachine]);
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $newMachine]);
return $this->json($this->normalizeStructureResponse(
$newMachine,
$componentLinks,
$pieceLinks,
$productLinks
), 201);
}
private function cloneCustomFields(Machine $source, Machine $target): void
{
foreach ($source->getCustomFields() as $cf) {
$newCf = new CustomField();
$newCf->setName($cf->getName());
$newCf->setType($cf->getType());
$newCf->setRequired($cf->isRequired());
$newCf->setDefaultValue($cf->getDefaultValue());
$newCf->setOptions($cf->getOptions());
$newCf->setOrderIndex($cf->getOrderIndex());
$newCf->setMachine($target);
$this->entityManager->persist($newCf);
}
foreach ($source->getCustomFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setMachine($target);
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$this->entityManager->persist($newValue);
}
}
/**
* @return array<string, MachineComponentLink> Map of old link ID → new link
*/
private function cloneComponentLinks(Machine $source, Machine $target): array
{
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source]);
$linkMap = [];
// First pass: create all links without parent relationships
foreach ($sourceLinks as $link) {
$newLink = new MachineComponentLink();
$newLink->setMachine($target);
$newLink->setComposant($link->getComposant());
$newLink->setNameOverride($link->getNameOverride());
$newLink->setReferenceOverride($link->getReferenceOverride());
$newLink->setPrixOverride($link->getPrixOverride());
$this->entityManager->persist($newLink);
$linkMap[$link->getId()] = $newLink;
}
// Second pass: set parent relationships
foreach ($sourceLinks as $link) {
$parent = $link->getParentLink();
if ($parent && isset($linkMap[$parent->getId()])) {
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
}
}
return $linkMap;
}
/**
* @param array<string, MachineComponentLink> $componentLinkMap
*
* @return array<string, MachinePieceLink> Map of old link ID → new link
*/
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array
{
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source]);
$linkMap = [];
foreach ($sourceLinks as $link) {
$newLink = new MachinePieceLink();
$newLink->setMachine($target);
$newLink->setPiece($link->getPiece());
$newLink->setNameOverride($link->getNameOverride());
$newLink->setReferenceOverride($link->getReferenceOverride());
$newLink->setPrixOverride($link->getPrixOverride());
$parent = $link->getParentLink();
if ($parent && isset($componentLinkMap[$parent->getId()])) {
$newLink->setParentLink($componentLinkMap[$parent->getId()]);
}
$this->entityManager->persist($newLink);
$linkMap[$link->getId()] = $newLink;
}
return $linkMap;
}
/**
* @param array<string, MachineComponentLink> $componentLinkMap
* @param array<string, MachinePieceLink> $pieceLinkMap
*/
private function cloneProductLinks(
Machine $source,
Machine $target,
array $componentLinkMap,
array $pieceLinkMap,
): void {
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source]);
$linkMap = [];
// First pass: create all links
foreach ($sourceLinks as $link) {
$newLink = new MachineProductLink();
$newLink->setMachine($target);
$newLink->setProduct($link->getProduct());
$parentComponent = $link->getParentComponentLink();
if ($parentComponent && isset($componentLinkMap[$parentComponent->getId()])) {
$newLink->setParentComponentLink($componentLinkMap[$parentComponent->getId()]);
}
$parentPiece = $link->getParentPieceLink();
if ($parentPiece && isset($pieceLinkMap[$parentPiece->getId()])) {
$newLink->setParentPieceLink($pieceLinkMap[$parentPiece->getId()]);
}
$this->entityManager->persist($newLink);
$linkMap[$link->getId()] = $newLink;
}
// Second pass: set parent product link relationships
foreach ($sourceLinks as $link) {
$parent = $link->getParentLink();
if ($parent && isset($linkMap[$parent->getId()])) {
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
}
}
}
private function normalizePayloadList(mixed $value): array
{
if (!is_array($value)) {
return [];
}
return array_values(array_filter($value, static fn ($item) => is_array($item)));
}
private function applyComponentLinks(Machine $machine, array $payload): array|JsonResponse
{
$existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine]));
$keepIds = [];
$pendingParents = [];
$links = [];
foreach ($payload as $entry) {
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineComponentLink();
if (!$linkId) {
$linkId = $this->generateCuid();
}
if (!$link->getId()) {
$link->setId($linkId);
}
$composantId = $this->resolveIdentifier($entry, ['composantId', 'componentId', 'idComposant']);
if (!$composantId) {
return $this->json(['success' => false, 'error' => 'Composant requis.'], 400);
}
$composant = $this->composantRepository->find($composantId);
if (!$composant instanceof Composant) {
return $this->json(['success' => false, 'error' => 'Composant introuvable.'], 404);
}
$link->setMachine($machine);
$link->setComposant($composant);
$this->applyOverrides($link, $entry['overrides'] ?? null);
$pendingParents[$linkId] = $this->resolveIdentifier($entry, [
'parentComponentLinkId',
'parentLinkId',
'parentMachineComponentLinkId',
]);
$this->entityManager->persist($link);
$links[$linkId] = $link;
$keepIds[] = $linkId;
}
foreach ($pendingParents as $linkId => $parentId) {
if (!$parentId || !isset($links[$linkId])) {
continue;
}
$parent = $links[$parentId] ?? $existing[$parentId] ?? null;
if ($parent instanceof MachineComponentLink) {
$links[$linkId]->setParentLink($parent);
}
}
$this->removeMissingLinks($existing, $keepIds);
return array_values($links);
}
private function applyPieceLinks(Machine $machine, array $payload, array $componentLinks): array|JsonResponse
{
$existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine]));
$componentIndex = $this->indexLinksById($componentLinks);
$keepIds = [];
$pendingParents = [];
$links = [];
foreach ($payload as $entry) {
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachinePieceLink();
if (!$linkId) {
$linkId = $this->generateCuid();
}
if (!$link->getId()) {
$link->setId($linkId);
}
$pieceId = $this->resolveIdentifier($entry, ['pieceId']);
if (!$pieceId) {
return $this->json(['success' => false, 'error' => 'Pièce requise.'], 400);
}
$piece = $this->pieceRepository->find($pieceId);
if (!$piece instanceof Piece) {
return $this->json(['success' => false, 'error' => 'Pièce introuvable.'], 404);
}
$link->setMachine($machine);
$link->setPiece($piece);
$this->applyOverrides($link, $entry['overrides'] ?? null);
$pendingParents[$linkId] = $this->resolveIdentifier($entry, [
'parentComponentLinkId',
'parentLinkId',
'parentMachineComponentLinkId',
]);
$this->entityManager->persist($link);
$links[$linkId] = $link;
$keepIds[] = $linkId;
}
foreach ($pendingParents as $linkId => $parentId) {
if (!$parentId || !isset($links[$linkId])) {
continue;
}
$parent = $componentIndex[$parentId] ?? null;
if ($parent instanceof MachineComponentLink) {
$links[$linkId]->setParentLink($parent);
}
}
$this->removeMissingLinks($existing, $keepIds);
return array_values($links);
}
private function applyProductLinks(
Machine $machine,
array $payload,
array $componentLinks,
array $pieceLinks,
): array|JsonResponse {
$existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine]));
$componentIndex = $this->indexLinksById($componentLinks);
$pieceIndex = $this->indexLinksById($pieceLinks);
$keepIds = [];
$pendingParents = [];
$links = [];
foreach ($payload as $entry) {
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineProductLink();
if (!$linkId) {
$linkId = $this->generateCuid();
}
if (!$link->getId()) {
$link->setId($linkId);
}
$productId = $this->resolveIdentifier($entry, ['productId']);
if (!$productId) {
return $this->json(['success' => false, 'error' => 'Produit requis.'], 400);
}
$product = $this->productRepository->find($productId);
if (!$product instanceof Product) {
return $this->json(['success' => false, 'error' => 'Produit introuvable.'], 404);
}
$link->setMachine($machine);
$link->setProduct($product);
$pendingParents[$linkId] = [
'parentComponentLinkId' => $this->resolveIdentifier($entry, ['parentComponentLinkId']),
'parentPieceLinkId' => $this->resolveIdentifier($entry, ['parentPieceLinkId']),
'parentLinkId' => $this->resolveIdentifier($entry, ['parentLinkId']),
];
$this->entityManager->persist($link);
$links[$linkId] = $link;
$keepIds[] = $linkId;
}
foreach ($pendingParents as $linkId => $parentIds) {
if (!isset($links[$linkId])) {
continue;
}
if (!empty($parentIds['parentComponentLinkId']) && isset($componentIndex[$parentIds['parentComponentLinkId']])) {
$links[$linkId]->setParentComponentLink($componentIndex[$parentIds['parentComponentLinkId']]);
}
if (!empty($parentIds['parentPieceLinkId']) && isset($pieceIndex[$parentIds['parentPieceLinkId']])) {
$links[$linkId]->setParentPieceLink($pieceIndex[$parentIds['parentPieceLinkId']]);
}
if (!empty($parentIds['parentLinkId']) && isset($links[$parentIds['parentLinkId']])) {
$links[$linkId]->setParentLink($links[$parentIds['parentLinkId']]);
}
}
$this->removeMissingLinks($existing, $keepIds);
return array_values($links);
}
private function normalizeStructureResponse(
Machine $machine,
array $componentLinks,
array $pieceLinks,
array $productLinks,
): array {
$normalizedComponentLinks = $this->normalizeComponentLinks($componentLinks);
$componentIndex = $this->indexNormalizedLinks($normalizedComponentLinks);
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
$childIds = [];
foreach ($normalizedComponentLinks as $link) {
$parentId = $link['parentComponentLinkId'] ?? null;
if ($parentId && isset($componentIndex[$parentId])) {
$componentIndex[$parentId]['childLinks'][] = $link;
$childIds[$link['id']] = true;
}
}
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
$rootComponents = array_filter(
$componentIndex,
static fn (array $link) => !isset($childIds[$link['id']]),
);
return [
'machine' => $this->normalizeMachine($machine),
'componentLinks' => array_values($rootComponents),
'pieceLinks' => $normalizedPieceLinks,
'productLinks' => $this->normalizeProductLinks($productLinks),
];
}
private function attachPiecesToComponents(array &$componentIndex, array $pieceLinks): void
{
foreach ($pieceLinks as $pieceLink) {
$parentId = $pieceLink['parentComponentLinkId'] ?? null;
if ($parentId && isset($componentIndex[$parentId])) {
$componentIndex[$parentId]['pieceLinks'][] = $pieceLink;
}
}
foreach ($componentIndex as &$component) {
if (!empty($component['childLinks'])) {
$this->attachPiecesToChildComponents($component['childLinks'], $pieceLinks);
}
}
}
private function attachPiecesToChildComponents(array &$childLinks, array $pieceLinks): void
{
foreach ($childLinks as &$child) {
$childId = $child['id'] ?? $child['linkId'] ?? null;
if ($childId) {
foreach ($pieceLinks as $pieceLink) {
$parentId = $pieceLink['parentComponentLinkId'] ?? null;
if ($parentId === $childId) {
$child['pieceLinks'][] = $pieceLink;
}
}
}
if (!empty($child['childLinks'])) {
$this->attachPiecesToChildComponents($child['childLinks'], $pieceLinks);
}
}
}
private function normalizeMachine(Machine $machine): array
{
$site = $machine->getSite();
return [
'id' => $machine->getId(),
'name' => $machine->getName(),
'reference' => $machine->getReference(),
'prix' => $machine->getPrix(),
'siteId' => $site->getId(),
'site' => [
'id' => $site->getId(),
'name' => $site->getName(),
],
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
'customFields' => $this->normalizeCustomFields($machine->getCustomFields()),
'documents' => null,
'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()),
];
}
private function normalizeCustomFields(Collection $customFields): array
{
$items = [];
foreach ($customFields as $customField) {
if (!$customField instanceof CustomField) {
continue;
}
$items[] = [
'id' => $customField->getId(),
'name' => $customField->getName(),
'type' => $customField->getType(),
'required' => $customField->isRequired(),
'options' => $customField->getOptions(),
'defaultValue' => $customField->getDefaultValue(),
'orderIndex' => $customField->getOrderIndex(),
];
}
return $items;
}
private function normalizeComponentLinks(array $links): array
{
return array_map(function (MachineComponentLink $link): array {
$composant = $link->getComposant();
$parentLink = $link->getParentLink();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'composantId' => $composant->getId(),
'composant' => $this->normalizeComposant($composant),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
'childLinks' => [],
'pieceLinks' => [],
];
}, $links);
}
private function normalizePieceLinks(array $links): array
{
return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece();
$parentLink = $link->getParentLink();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'pieceId' => $piece->getId(),
'piece' => $this->normalizePiece($piece),
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'overrides' => $this->normalizeOverrides($link),
];
}, $links);
}
private function normalizeProductLinks(array $links): array
{
return array_map(function (MachineProductLink $link): array {
$product = $link->getProduct();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'productId' => $product->getId(),
'product' => $this->normalizeProduct($product),
'parentLinkId' => $link->getParentLink()?->getId(),
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
];
}, $links);
}
private function normalizeComposant(Composant $composant): array
{
$type = $composant->getTypeComposant();
return [
'id' => $composant->getId(),
'name' => $composant->getName(),
'reference' => $composant->getReference(),
'prix' => $composant->getPrix(),
'typeComposantId' => $type?->getId(),
'typeComposant' => $this->normalizeModelType($type),
'productId' => $composant->getProduct()?->getId(),
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
'documents' => [],
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [],
'customFieldValues' => $this->normalizeCustomFieldValues($composant->getCustomFieldValues()),
];
}
private function normalizePiece(Piece $piece): array
{
$type = $piece->getTypePiece();
return [
'id' => $piece->getId(),
'name' => $piece->getName(),
'reference' => $piece->getReference(),
'prix' => $piece->getPrix(),
'typePieceId' => $type?->getId(),
'typePiece' => $this->normalizeModelType($type),
'productId' => $piece->getProduct()?->getId(),
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()),
'documents' => [],
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getPieceCustomFields()) : [],
'customFieldValues' => $this->normalizeCustomFieldValues($piece->getCustomFieldValues()),
];
}
private function normalizeProduct(Product $product): array
{
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' => [],
];
}
private function normalizeModelType(?ModelType $type): ?array
{
if (!$type instanceof ModelType) {
return null;
}
return [
'id' => $type->getId(),
'name' => $type->getName(),
'code' => $type->getCode(),
'category' => $type->getCategory()->value,
'structure' => $type->getStructure(),
];
}
private function normalizeConstructeurs(Collection $constructeurs): array
{
$items = [];
foreach ($constructeurs as $constructeur) {
$items[] = [
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(),
'phone' => $constructeur->getPhone(),
];
}
return $items;
}
private function normalizeCustomFieldDefinitions(Collection $customFields): array
{
$items = [];
foreach ($customFields as $cf) {
if (!$cf instanceof CustomField) {
continue;
}
$items[] = [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
];
}
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
return $items;
}
private function normalizeCustomFieldValues(Collection $customFieldValues): array
{
$items = [];
foreach ($customFieldValues as $cfv) {
if (!$cfv instanceof CustomFieldValue) {
continue;
}
$cf = $cfv->getCustomField();
$items[] = [
'id' => $cfv->getId(),
'value' => $cfv->getValue(),
'customField' => [
'id' => $cf->getId(),
'name' => $cf->getName(),
'type' => $cf->getType(),
'required' => $cf->isRequired(),
'options' => $cf->getOptions(),
'defaultValue' => $cf->getDefaultValue(),
'orderIndex' => $cf->getOrderIndex(),
],
];
}
return $items;
}
private function normalizeOverrides(object $link): ?array
{
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;
$reference = method_exists($link, 'getReferenceOverride') ? $link->getReferenceOverride() : null;
$prix = method_exists($link, 'getPrixOverride') ? $link->getPrixOverride() : null;
if (null === $name && null === $reference && null === $prix) {
return null;
}
return [
'name' => $name,
'reference' => $reference,
'prix' => $prix,
];
}
private function applyOverrides(object $link, mixed $overrides): void
{
if (!is_array($overrides)) {
return;
}
if (array_key_exists('name', $overrides) && method_exists($link, 'setNameOverride')) {
$link->setNameOverride($this->stringOrNull($overrides['name']));
}
if (array_key_exists('reference', $overrides) && method_exists($link, 'setReferenceOverride')) {
$link->setReferenceOverride($this->stringOrNull($overrides['reference']));
}
if (array_key_exists('prix', $overrides) && method_exists($link, 'setPrixOverride')) {
$link->setPrixOverride($this->stringOrNull($overrides['prix']));
}
}
private function stringOrNull(mixed $value): ?string
{
if (null === $value) {
return null;
}
$string = trim((string) $value);
return '' === $string ? null : $string;
}
private function resolveIdentifier(array $entry, array $keys): ?string
{
foreach ($keys as $key) {
if (!array_key_exists($key, $entry)) {
continue;
}
$value = $entry[$key];
if (null === $value || '' === $value) {
continue;
}
return (string) $value;
}
return null;
}
/**
* @param array<array-key, object> $links
*
* @return array<string, object>
*/
private function indexLinksById(array $links): array
{
$indexed = [];
foreach ($links as $link) {
if (method_exists($link, 'getId') && $link->getId()) {
$indexed[$link->getId()] = $link;
}
}
return $indexed;
}
private function indexNormalizedLinks(array $links): array
{
$indexed = [];
foreach ($links as $link) {
if (is_array($link) && isset($link['id'])) {
$indexed[$link['id']] = $link;
}
}
return $indexed;
}
private function removeMissingLinks(array $existing, array $keepIds): void
{
$keep = array_flip($keepIds);
foreach ($existing as $link) {
if (!method_exists($link, 'getId')) {
continue;
}
$id = $link->getId();
if ($id && !isset($keep[$id])) {
$this->entityManager->remove($link);
}
}
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\ModelTypeRepository;
use App\Service\ModelTypeCategoryConversionService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ModelTypeConversionController extends AbstractController
{
public function __construct(
private readonly ModelTypeRepository $modelTypes,
private readonly ModelTypeCategoryConversionService $conversionService,
) {}
#[Route('/api/model_types/{id}/conversion-check', name: 'api_model_type_conversion_check', methods: ['GET'])]
public function check(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$modelType = $this->modelTypes->find($id);
if (!$modelType) {
return new JsonResponse(
['message' => 'Catégorie introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
return new JsonResponse($this->conversionService->checkConversion($id));
}
#[Route('/api/model_types/{id}/convert', name: 'api_model_type_convert', methods: ['POST'])]
public function convert(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
$modelType = $this->modelTypes->find($id);
if (!$modelType) {
return new JsonResponse(
['message' => 'Catégorie introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$result = $this->conversionService->convert($id);
if (!$result['success']) {
return new JsonResponse($result, Response::HTTP_CONFLICT);
}
return new JsonResponse($result);
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\ProfileRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
final class SessionProfileController
{
public function __construct(
private readonly ProfileRepository $profiles,
private readonly UserPasswordHasherInterface $passwordHasher,
) {}
#[Route('/api/session/profile', name: 'api_session_profile_get', methods: ['GET'])]
public function getActiveProfile(Request $request): JsonResponse
{
$session = $request->getSession();
if (!$session instanceof SessionInterface) {
return new JsonResponse(['message' => 'Session indisponible.'], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
}
$profileId = $session->get('profileId');
if (!$profileId) {
return new JsonResponse(['message' => 'Aucun profil actif.'], JsonResponse::HTTP_UNAUTHORIZED);
}
$profile = $this->profiles->find($profileId);
if (!$profile || !$profile->isActive()) {
$session->remove('profileId');
return new JsonResponse(['message' => 'Profil introuvable ou inactif.'], JsonResponse::HTTP_UNAUTHORIZED);
}
return new JsonResponse([
'id' => $profile->getId(),
'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(),
'email' => $profile->getEmail(),
'isActive' => $profile->isActive(),
'roles' => $profile->getRoles(),
]);
}
#[Route('/api/session/profile', name: 'api_session_profile_post', methods: ['POST'])]
public function activateProfile(Request $request): JsonResponse
{
$session = $request->getSession();
if (!$session instanceof SessionInterface) {
return new JsonResponse(['message' => 'Session indisponible.'], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
}
$payload = $request->toArray();
$profileId = $payload['profileId'] ?? null;
if (!$profileId) {
return new JsonResponse(['message' => 'profileId est requis.'], JsonResponse::HTTP_BAD_REQUEST);
}
$profile = $this->profiles->find($profileId);
if (!$profile || !$profile->isActive()) {
return new JsonResponse(['message' => 'Profil introuvable ou inactif.'], JsonResponse::HTTP_UNAUTHORIZED);
}
$password = $payload['password'] ?? '';
if ('' === $password) {
return new JsonResponse(['message' => 'Mot de passe requis.'], JsonResponse::HTTP_BAD_REQUEST);
}
if (!$profile->getPassword()) {
return new JsonResponse(
['message' => 'Ce profil n\'a pas de mot de passe. Contactez un administrateur.'],
JsonResponse::HTTP_FORBIDDEN,
);
}
if (!$this->passwordHasher->isPasswordValid($profile, $password)) {
return new JsonResponse(['message' => 'Mot de passe incorrect.'], JsonResponse::HTTP_UNAUTHORIZED);
}
$session->migrate(true);
$session->set('profileId', $profile->getId());
$session->set('profileRoles', $profile->getRoles());
return new JsonResponse([
'id' => $profile->getId(),
'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(),
'email' => $profile->getEmail(),
'isActive' => $profile->isActive(),
'roles' => $profile->getRoles(),
]);
}
#[Route('/api/session/profile', name: 'api_session_profile_delete', methods: ['DELETE'])]
public function logout(Request $request): JsonResponse
{
$session = $request->getSession();
if ($session instanceof SessionInterface) {
$session->invalidate();
}
return new JsonResponse(['success' => true]);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\ProfileRepository;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
final class SessionProfilesController
{
public function __construct(
private readonly ProfileRepository $profiles,
) {}
#[Route('/api/session/profiles', name: 'api_session_profiles_list', methods: ['GET'])]
public function list(): JsonResponse
{
$items = $this->profiles->createQueryBuilder('p')
->andWhere('p.isActive = :active')
->setParameter('active', true)
->orderBy('p.firstName', 'ASC')
->getQuery()
->getResult()
;
return new JsonResponse(array_map(static function ($profile): array {
return [
'id' => $profile->getId(),
'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(),
'hasPassword' => null !== $profile->getPassword() && '' !== $profile->getPassword(),
];
}, $items));
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Doctrine\QuoteStrategy;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\ORM\Internal\SQLResultCasing;
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Mapping\JoinColumnMapping;
use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping;
use Doctrine\ORM\Mapping\QuoteStrategy;
use function array_map;
use function array_merge;
use function assert;
use function explode;
use function implode;
/**
* Quote all identifiers to preserve camelCase column names in Postgres.
*/
final class AlwaysQuoteStrategy implements QuoteStrategy
{
use SQLResultCasing;
public function getColumnName(string $fieldName, ClassMetadata $class, AbstractPlatform $platform): string
{
return $platform->quoteSingleIdentifier($class->fieldMappings[$fieldName]->columnName);
}
public function getTableName(ClassMetadata $class, AbstractPlatform $platform): string
{
$tableName = $platform->quoteSingleIdentifier($class->table['name']);
if (!empty($class->table['schema'])) {
return $platform->quoteSingleIdentifier($class->table['schema']).'.'.$tableName;
}
return $tableName;
}
public function getSequenceName(array $definition, ClassMetadata $class, AbstractPlatform $platform): string
{
return implode('.', array_map(
static fn (string $part) => $platform->quoteSingleIdentifier($part),
explode('.', $definition['sequenceName']),
));
}
public function getJoinTableName(
ManyToManyOwningSideMapping $association,
ClassMetadata $class,
AbstractPlatform $platform,
): string {
$schema = '';
if (isset($association->joinTable->schema)) {
$schema = $platform->quoteSingleIdentifier($association->joinTable->schema).'.';
}
return $schema.$platform->quoteSingleIdentifier($association->joinTable->name);
}
public function getJoinColumnName(JoinColumnMapping $joinColumn, ClassMetadata $class, AbstractPlatform $platform): string
{
return $platform->quoteSingleIdentifier($joinColumn->name);
}
public function getReferencedJoinColumnName(
JoinColumnMapping $joinColumn,
ClassMetadata $class,
AbstractPlatform $platform,
): string {
return $platform->quoteSingleIdentifier($joinColumn->referencedColumnName);
}
public function getIdentifierColumnNames(ClassMetadata $class, AbstractPlatform $platform): array
{
$quotedColumnNames = [];
foreach ($class->identifier as $fieldName) {
if (isset($class->fieldMappings[$fieldName])) {
$quotedColumnNames[] = $this->getColumnName($fieldName, $class, $platform);
continue;
}
$assoc = $class->associationMappings[$fieldName];
assert($assoc->isToOneOwningSide());
$joinColumns = $assoc->joinColumns;
$assocQuotedColumnNames = array_map(
static fn (JoinColumnMapping $joinColumn) => $platform->quoteSingleIdentifier($joinColumn->name),
$joinColumns,
);
$quotedColumnNames = array_merge($quotedColumnNames, $assocQuotedColumnNames);
}
return $quotedColumnNames;
}
public function getColumnAlias(
string $columnName,
int $counter,
AbstractPlatform $platform,
?ClassMetadata $class = null,
): string {
return $this->getSQLResultCasing($platform, $columnName.'_'.$counter);
}
}

117
src/Entity/AuditLog.php Normal file
View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\AuditLogRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AuditLogRepository::class)]
#[ORM\Table(name: 'audit_logs')]
#[ORM\Index(name: 'idx_audit_entity', columns: ['entityType', 'entityId'])]
#[ORM\Index(name: 'idx_audit_created_at', columns: ['createdAt'])]
#[ORM\HasLifecycleCallbacks]
class AuditLog
{
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 50)]
private string $entityType;
#[ORM\Column(type: Types::STRING, length: 36)]
private string $entityId;
#[ORM\Column(type: Types::STRING, length: 20)]
private string $action;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $diff = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
private ?array $snapshot = null;
#[ORM\Column(type: Types::STRING, length: 36, nullable: true)]
private ?string $actorProfileId = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
public function __construct(
string $entityType,
string $entityId,
string $action,
?array $diff = null,
?array $snapshot = null,
?string $actorProfileId = null,
) {
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->action = $action;
$this->diff = $diff;
$this->snapshot = $snapshot;
$this->actorProfileId = $actorProfileId;
}
#[ORM\PrePersist]
public function initializeAuditLog(): void
{
if (!isset($this->createdAt)) {
$this->createdAt = new DateTimeImmutable();
}
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
public function getId(): ?string
{
return $this->id;
}
public function getEntityType(): string
{
return $this->entityType;
}
public function getEntityId(): string
{
return $this->entityId;
}
public function getAction(): string
{
return $this->action;
}
public function getDiff(): ?array
{
return $this->diff;
}
public function getSnapshot(): ?array
{
return $this->snapshot;
}
public function getActorProfileId(): ?string
{
return $this->actorProfileId;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
private function generateCuid(): string
{
// Keep the same lightweight CUID-like strategy used across the project.
return 'cl'.substr(strtolower(base_convert(bin2hex(random_bytes(12)), 16, 36)), 0, 24);
}
}

207
src/Entity/Comment.php Normal file
View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use App\Entity\Trait\CuidEntityTrait;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'comments')]
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_comment_entity_status')]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['entityType' => 'exact', 'entityId' => 'exact', 'status' => 'exact', 'entityName' => 'ipartial'])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'authorName', 'status'])]
#[ApiResource(
description: 'Commentaires et annotations. Permet aux utilisateurs de commenter les machines, pièces, composants, produits et catégories. Les commentaires peuvent être marqués comme résolus.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
order: ['createdAt' => 'DESC'],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Comment
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\Column(type: Types::TEXT)]
private string $content;
#[ORM\Column(type: Types::STRING, length: 50, name: 'entity_type')]
private string $entityType;
#[ORM\Column(type: Types::STRING, length: 36, name: 'entity_id')]
private string $entityId;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'entity_name')]
private ?string $entityName = null;
#[ORM\Column(type: Types::STRING, length: 36, name: 'author_id')]
private string $authorId;
#[ORM\Column(type: Types::STRING, length: 255, name: 'author_name')]
private string $authorName;
#[ORM\Column(type: Types::STRING, length: 20)]
private string $status = 'open';
#[ORM\Column(type: Types::STRING, length: 36, nullable: true, name: 'resolved_by_id')]
private ?string $resolvedById = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'resolved_by_name')]
private ?string $resolvedByName = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true, name: 'resolved_at')]
private ?DateTimeImmutable $resolvedAt = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'created_at')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getEntityType(): string
{
return $this->entityType;
}
public function setEntityType(string $entityType): static
{
$this->entityType = $entityType;
return $this;
}
public function getEntityId(): string
{
return $this->entityId;
}
public function setEntityId(string $entityId): static
{
$this->entityId = $entityId;
return $this;
}
public function getEntityName(): ?string
{
return $this->entityName;
}
public function setEntityName(?string $entityName): static
{
$this->entityName = $entityName;
return $this;
}
public function getAuthorId(): string
{
return $this->authorId;
}
public function setAuthorId(string $authorId): static
{
$this->authorId = $authorId;
return $this;
}
public function getAuthorName(): string
{
return $this->authorName;
}
public function setAuthorName(string $authorName): static
{
$this->authorName = $authorName;
return $this;
}
public function getStatus(): string
{
return $this->status;
}
public function setStatus(string $status): static
{
$this->status = $status;
return $this;
}
public function getResolvedById(): ?string
{
return $this->resolvedById;
}
public function setResolvedById(?string $resolvedById): static
{
$this->resolvedById = $resolvedById;
return $this;
}
public function getResolvedByName(): ?string
{
return $this->resolvedByName;
}
public function setResolvedByName(?string $resolvedByName): static
{
$this->resolvedByName = $resolvedByName;
return $this;
}
public function getResolvedAt(): ?DateTimeImmutable
{
return $this->resolvedAt;
}
public function setResolvedAt(?DateTimeImmutable $resolvedAt): static
{
$this->resolvedAt = $resolvedAt;
return $this;
}
}

273
src/Entity/Composant.php Normal file
View File

@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ComposantRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: ComposantRepository::class)]
#[ORM\Table(name: 'composants')]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact', 'typeComposant.name' => 'ipartial'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
#[ApiResource(
description: 'Composants du catalogue. Un composant représente un élément fonctionnel rattaché à une machine, avec un type, des fournisseurs et des documents.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
normalizationContext: ['groups' => ['composant:read']],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Composant
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'document:list'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Groups(['composant:read', 'document:list'])]
private string $name;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[Groups(['composant:read'])]
private ?string $reference = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['composant:read'])]
private ?string $description = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
#[Groups(['composant:read'])]
private ?string $prix = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['composant:read'])]
private ?array $structure = null;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'composants')]
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true)]
#[Groups(['composant:read'])]
private ?ModelType $typeComposant = null;
#[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'composants')]
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['composant:read'])]
private ?Product $product = null;
/**
* @var Collection<int, Constructeur>
*/
#[ORM\ManyToMany(targetEntity: Constructeur::class, inversedBy: 'composants')]
#[ORM\JoinTable(
name: '_ComposantConstructeurs',
joinColumns: [new ORM\JoinColumn(name: 'A', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\InverseJoinColumn(name: 'B', referencedColumnName: 'id', onDelete: 'CASCADE')]
)]
#[Groups(['composant:read'])]
private Collection $constructeurs;
/**
* @var Collection<int, Document>
*/
#[ORM\OneToMany(mappedBy: 'composant', targetEntity: Document::class)]
#[Groups(['composant:read'])]
private Collection $documents;
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'composant', targetEntity: CustomFieldValue::class)]
#[Groups(['composant:read'])]
private Collection $customFieldValues;
/**
* @var Collection<int, MachineComponentLink>
*/
#[ORM\OneToMany(mappedBy: 'composant', targetEntity: MachineComponentLink::class)]
private Collection $machineLinks;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['composant:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
#[Groups(['composant:read'])]
private DateTimeImmutable $updatedAt;
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();
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = mb_strtoupper(mb_substr($name, 0, 1)).mb_substr($name, 1);
return $this;
}
public function getReference(): ?string
{
return $this->reference;
}
public function setReference(?string $reference): static
{
$this->reference = $reference;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getPrix(): ?string
{
return $this->prix;
}
public function setPrix(?string $prix): static
{
$this->prix = $prix;
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;
}
public function setTypeComposant(?ModelType $typeComposant): static
{
$this->typeComposant = $typeComposant;
return $this;
}
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): static
{
$this->product = $product;
return $this;
}
/**
* @return Collection<int, Constructeur>
*/
public function getConstructeurs(): Collection
{
return $this->constructeurs;
}
/**
* @param iterable<Constructeur> $constructeurs
*/
public function setConstructeurs(iterable $constructeurs): static
{
$this->constructeurs = new ArrayCollection();
foreach ($constructeurs as $constructeur) {
if ($constructeur instanceof Constructeur && !$this->constructeurs->contains($constructeur)) {
$this->constructeurs->add($constructeur);
}
}
return $this;
}
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, Document>
*/
public function getDocuments(): Collection
{
return $this->documents;
}
/**
* @return Collection<int, CustomFieldValue>
*/
public function getCustomFieldValues(): Collection
{
return $this->customFieldValues;
}
}

134
src/Entity/Constructeur.php Normal file
View File

@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ConstructeurRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')]
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
#[ORM\Table(name: 'constructeurs')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
)]
class Constructeur
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
private ?string $name = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $email = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $phone = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt;
/**
* @var Collection<int, Machine>
*/
#[ORM\ManyToMany(targetEntity: Machine::class, mappedBy: 'constructeurs')]
private Collection $machines;
/**
* @var Collection<int, Composant>
*/
#[ORM\ManyToMany(targetEntity: Composant::class, mappedBy: 'constructeurs')]
private Collection $composants;
/**
* @var Collection<int, Piece>
*/
#[ORM\ManyToMany(targetEntity: Piece::class, mappedBy: 'constructeurs')]
private Collection $pieces;
/**
* @var Collection<int, Product>
*/
#[ORM\ManyToMany(targetEntity: Product::class, mappedBy: 'constructeurs')]
private Collection $products;
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();
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
return $this;
}
}

187
src/Entity/CustomField.php Normal file
View File

@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\CustomFieldRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: CustomFieldRepository::class)]
#[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')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
]
)]
class CustomField
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private string $name;
#[ORM\Column(type: Types::STRING, length: 50)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private string $type;
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false])]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private bool $required = false;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'defaultValue')]
private ?string $defaultValue = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private ?array $options = null;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0], name: 'orderIndex')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private int $orderIndex = 0;
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'customFields')]
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Machine $machine = null;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'customFields')]
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?ModelType $typeComposant = null;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'pieceCustomFields')]
#[ORM\JoinColumn(name: 'typePieceId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?ModelType $typePiece = null;
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'productCustomFields')]
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?ModelType $typeProduct = null;
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'customField', targetEntity: CustomFieldValue::class)]
private Collection $customFieldValues;
#[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();
$this->customFieldValues = new ArrayCollection();
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getType(): string
{
return $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function isRequired(): bool
{
return $this->required;
}
public function setRequired(bool $required): static
{
$this->required = $required;
return $this;
}
public function getDefaultValue(): ?string
{
return $this->defaultValue;
}
public function setDefaultValue(?string $defaultValue): static
{
$this->defaultValue = $defaultValue;
return $this;
}
public function getOptions(): ?array
{
return $this->options;
}
public function setOptions(?array $options): static
{
$this->options = $options;
return $this;
}
public function getOrderIndex(): int
{
return $this->orderIndex;
}
public function setOrderIndex(int $orderIndex): static
{
$this->orderIndex = $orderIndex;
return $this;
}
public function getMachine(): ?Machine
{
return $this->machine;
}
public function setMachine(?Machine $machine): static
{
$this->machine = $machine;
return $this;
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\CustomFieldValueRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: CustomFieldValueRepository::class)]
#[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')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
]
)]
class CustomFieldValue
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private string $value;
#[ORM\ManyToOne(targetEntity: CustomField::class, inversedBy: 'customFieldValues')]
#[ORM\JoinColumn(name: 'customFieldId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private CustomField $customField;
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'customFieldValues')]
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Machine $machine = null;
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'customFieldValues')]
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Composant $composant = null;
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'customFieldValues')]
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Piece $piece = null;
#[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'customFieldValues')]
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Product $product = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
public function getValue(): string
{
return $this->value;
}
public function setValue(string $value): static
{
$this->value = $value;
return $this;
}
public function getCustomField(): CustomField
{
return $this->customField;
}
public function setCustomField(CustomField $customField): static
{
$this->customField = $customField;
return $this;
}
public function getMachine(): ?Machine
{
return $this->machine;
}
public function setMachine(?Machine $machine): static
{
$this->machine = $machine;
return $this;
}
public function getComposant(): ?Composant
{
return $this->composant;
}
public function setComposant(?Composant $composant): static
{
$this->composant = $composant;
return $this;
}
public function getPiece(): ?Piece
{
return $this->piece;
}
public function setPiece(?Piece $piece): static
{
$this->piece = $piece;
return $this;
}
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): static
{
$this->product = $product;
return $this;
}
}

240
src/Entity/Document.php Normal file
View File

@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\DocumentRepository;
use App\State\DocumentUploadProcessor;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: DocumentRepository::class)]
#[ORM\Table(name: 'documents')]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'filename' => 'ipartial'])]
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product'])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])]
#[ApiResource(
description: 'Documents et fichiers. Gestion des fichiers joints (PDF, images, etc.) rattachés aux machines, pièces, composants, produits ou sites. Upload via multipart/form-data.',
operations: [
new GetCollection(
security: "is_granted('ROLE_VIEWER')",
normalizationContext: ['groups' => ['document:list']],
),
new Get(
security: "is_granted('ROLE_VIEWER')",
normalizationContext: ['groups' => ['document:list', 'document:detail']],
),
new Post(
security: "is_granted('ROLE_GESTIONNAIRE')",
processor: DocumentUploadProcessor::class,
deserialize: false,
inputFormats: ['multipart' => ['multipart/form-data']],
),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 500,
order: ['createdAt' => 'DESC']
)]
class Document
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $name;
#[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $filename;
#[ORM\Column(type: Types::TEXT)]
private string $path;
#[ORM\Column(type: Types::STRING, length: 100, name: 'mimeType')]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $mimeType;
#[ORM\Column(type: Types::INTEGER)]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private int $size;
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Machine $machine = null;
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Composant $composant = null;
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Piece $piece = null;
#[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Product $product = null;
#[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'siteId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Site $site = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['document:list'])]
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 getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getFilename(): string
{
return $this->filename;
}
public function setFilename(string $filename): static
{
$this->filename = $filename;
return $this;
}
public function getPath(): string
{
return $this->path;
}
public function setPath(string $path): static
{
$this->path = $path;
return $this;
}
public function getMimeType(): string
{
return $this->mimeType;
}
public function setMimeType(string $mimeType): static
{
$this->mimeType = $mimeType;
return $this;
}
public function getSize(): int
{
return $this->size;
}
public function setSize(int $size): static
{
$this->size = $size;
return $this;
}
public function getMachine(): ?Machine
{
return $this->machine;
}
public function setMachine(?Machine $machine): static
{
$this->machine = $machine;
return $this;
}
public function getComposant(): ?Composant
{
return $this->composant;
}
public function setComposant(?Composant $composant): static
{
$this->composant = $composant;
return $this;
}
public function getPiece(): ?Piece
{
return $this->piece;
}
public function setPiece(?Piece $piece): static
{
$this->piece = $piece;
return $this;
}
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): static
{
$this->product = $product;
return $this;
}
public function getSite(): ?Site
{
return $this->site;
}
public function setSite(?Site $site): static
{
$this->site = $site;
return $this;
}
}

252
src/Entity/Machine.php Normal file
View File

@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\MachineRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
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')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
]
)]
class Machine
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Groups(['document:list'])]
private string $name;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $reference = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true)]
private ?string $prix = null;
#[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'machines')]
#[ORM\JoinColumn(name: 'siteId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
#[Assert\NotNull(message: 'Le site est obligatoire.')]
private ?Site $site = null;
/**
* @var Collection<int, Constructeur>
*/
#[ORM\ManyToMany(targetEntity: Constructeur::class, inversedBy: 'machines')]
#[ORM\JoinTable(
name: '_MachineConstructeurs',
joinColumns: [new ORM\JoinColumn(name: 'A', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\InverseJoinColumn(name: 'B', referencedColumnName: 'id', onDelete: 'CASCADE')]
)]
private Collection $constructeurs;
/**
* @var Collection<int, MachineComponentLink>
*/
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: MachineComponentLink::class)]
private Collection $componentLinks;
/**
* @var Collection<int, MachinePieceLink>
*/
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: MachinePieceLink::class)]
private Collection $pieceLinks;
/**
* @var Collection<int, MachineProductLink>
*/
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: MachineProductLink::class)]
private Collection $productLinks;
/**
* @var Collection<int, Document>
*/
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: Document::class)]
private Collection $documents;
/**
* @var Collection<int, CustomField>
*/
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: CustomField::class, cascade: ['persist', 'remove'])]
private Collection $customFields;
/**
* @var Collection<int, CustomFieldValue>
*/
#[ORM\OneToMany(mappedBy: 'machine', targetEntity: CustomFieldValue::class)]
private Collection $customFieldValues;
#[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()
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
$this->constructeurs = new ArrayCollection();
$this->componentLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection();
$this->documents = new ArrayCollection();
$this->customFields = new ArrayCollection();
$this->customFieldValues = new ArrayCollection();
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getReference(): ?string
{
return $this->reference;
}
public function setReference(?string $reference): static
{
$this->reference = $reference;
return $this;
}
public function getPrix(): ?string
{
return $this->prix;
}
public function setPrix(?string $prix): static
{
$this->prix = $prix;
return $this;
}
public function getSite(): ?Site
{
return $this->site;
}
public function setSite(?Site $site): static
{
$this->site = $site;
return $this;
}
/**
* @return Collection<int, CustomField>
*/
public function getCustomFields(): Collection
{
return $this->customFields;
}
public function addCustomField(CustomField $customField): static
{
if (!$this->customFields->contains($customField)) {
$this->customFields->add($customField);
$customField->setMachine($this);
}
return $this;
}
public function removeCustomField(CustomField $customField): static
{
if ($this->customFields->removeElement($customField)) {
if ($customField->getMachine() === $this) {
$customField->setMachine(null);
}
}
return $this;
}
/**
* @return Collection<int, Constructeur>
*/
public function getConstructeurs(): Collection
{
return $this->constructeurs;
}
/**
* @return Collection<int, MachineComponentLink>
*/
public function getComponentLinks(): Collection
{
return $this->componentLinks;
}
/**
* @return Collection<int, MachinePieceLink>
*/
public function getPieceLinks(): Collection
{
return $this->pieceLinks;
}
/**
* @return Collection<int, MachineProductLink>
*/
public function getProductLinks(): Collection
{
return $this->productLinks;
}
/**
* @return Collection<int, Document>
*/
public function getDocuments(): Collection
{
return $this->documents;
}
/**
* @return Collection<int, CustomFieldValue>
*/
public function getCustomFieldValues(): Collection
{
return $this->customFieldValues;
}
}

View File

@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\MachineComponentLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: MachineComponentLinkRepository::class)]
#[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')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
]
)]
class MachineComponentLink
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'componentLinks')]
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Machine $machine;
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'machineLinks')]
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Composant $composant;
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'childLinks')]
#[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?MachineComponentLink $parentLink = null;
/**
* @var Collection<int, MachineComponentLink>
*/
#[ORM\OneToMany(mappedBy: 'parentLink', targetEntity: MachineComponentLink::class)]
private Collection $childLinks;
/**
* @var Collection<int, MachinePieceLink>
*/
#[ORM\OneToMany(mappedBy: 'parentLink', targetEntity: MachinePieceLink::class)]
private Collection $pieceLinks;
/**
* @var Collection<int, MachineProductLink>
*/
#[ORM\OneToMany(mappedBy: 'parentComponentLink', targetEntity: MachineProductLink::class)]
private Collection $productLinks;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'nameOverride')]
private ?string $nameOverride = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'referenceOverride')]
private ?string $referenceOverride = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true, name: 'prixOverride')]
private ?string $prixOverride = null;
#[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();
$this->childLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection();
}
public function getMachine(): Machine
{
return $this->machine;
}
public function setMachine(Machine $machine): static
{
$this->machine = $machine;
return $this;
}
public function getComposant(): Composant
{
return $this->composant;
}
public function setComposant(Composant $composant): static
{
$this->composant = $composant;
return $this;
}
public function getParentLink(): ?MachineComponentLink
{
return $this->parentLink;
}
public function setParentLink(?MachineComponentLink $parentLink): static
{
$this->parentLink = $parentLink;
return $this;
}
public function getNameOverride(): ?string
{
return $this->nameOverride;
}
public function setNameOverride(?string $nameOverride): static
{
$this->nameOverride = $nameOverride;
return $this;
}
public function getReferenceOverride(): ?string
{
return $this->referenceOverride;
}
public function setReferenceOverride(?string $referenceOverride): static
{
$this->referenceOverride = $referenceOverride;
return $this;
}
public function getPrixOverride(): ?string
{
return $this->prixOverride;
}
public function setPrixOverride(?string $prixOverride): static
{
$this->prixOverride = $prixOverride;
return $this;
}
}

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