Compare commits

...

64 Commits

Author SHA1 Message Date
Matthieu
9c8aecec93 feat : add Docker production deployment
Some checks failed
Build & Push Docker Image / build (push) Failing after 8s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:08:32 +02:00
Matthieu
476060cf7d WIP 2026-03-31 17:57:59 +02:00
Matthieu
1b1dab65b6 feat(constructeur) : add SearchFilter on ConstructeurLink entities
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:31:54 +02:00
Matthieu
5fff226f84 fix(constructeur) : fix remaining references after ConstructeurLink migration
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:50:05 +02:00
Matthieu
34b0d9225c test(constructeur) : update test helpers and MCP tests for ConstructeurLinks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:30:46 +02:00
Matthieu
691f632be0 refactor(mcp) : update MCP tools to use ConstructeurLinks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:29:10 +02:00
Matthieu
43fafc2251 refactor(conversion) : update category conversion to use ConstructeurLink tables
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:26:30 +02:00
Matthieu
0ad5815659 refactor(versioning) : update EntityVersionService to use ConstructeurLinks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:25:20 +02:00
Matthieu
a249a5b785 refactor(audit) : update audit subscribers to use ConstructeurLinks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:23:35 +02:00
Matthieu
d85272208a refactor(machines) : update structure controller to use ConstructeurLinks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:19:03 +02:00
Matthieu
26be0b655d refactor(constructeur) : replace ManyToMany with OneToMany to ConstructeurLink entities
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:12:17 +02:00
Matthieu
2d33c97449 feat(constructeur) : add 4 ConstructeurLink pivot entities with supplierReference
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:09:07 +02:00
Matthieu
03c2451990 feat(reference-auto) : extend auto-reference to composants + formula builder UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:59:42 +02:00
Matthieu
3f6ce153bb feat(reference-auto) : add automatic reference generation for pieces
ModelType defines a formula with placeholders ({serie}{diametre}{type}).
ReferenceAutoGenerator resolves it from CustomFieldValues with trim+uppercase normalisation.
ReferenceAutoSubscriber (onFlush) recalculates on Piece/CFV insert/update/delete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:58:53 +01:00
Matthieu
d568961eb3 feat(machine) : single save button + link versioning with restore
Backend:
- Enrich machine snapshot with componentLinks/pieceLinks/productLinks
- Detect link add/remove in MachineAuditSubscriber onFlush
- Add link diff comparison in restore preview
- Add link restoration in applyRestore for machines
- Add integrity warnings for missing linked entities

Frontend (submodule update):
- Single save button replacing auto-save-on-blur
- Link versioning display in version list and restore modal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:51:58 +01:00
Matthieu
9299a46c8b feat(versioning) : add entity versioning with numbered versions and restore
Backend:
- Migration: version column on audit_logs and machines
- AuditLog, Machine, Composant, Piece, Product: version + skipAudit properties
- AbstractAuditSubscriber: auto-increment version, skip on restore, fix decimal diff
- Enriched snapshots with slots, custom fields and version number
- AuditLogRepository: findVersionHistory, findByVersion
- EntityVersionService: list, preview, restore with skeleton/integrity checks
- EntityVersionController: REST endpoints for all 4 entity types
- 11 tests covering list, preview, restore, auth

Frontend: update submodule pointer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 15:01:56 +01:00
Matthieu
162c6ece71 chore : untrack auto-generated config/reference.php
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:39:29 +01:00
3f93781e16 docs(versioning) : add entity versioning implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:04:30 +01:00
a07145c78f chore(submodule) : update frontend pointer (fix form data loss on error)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 23:03:04 +01:00
586b7bb91d docs(versioning) : add entity versioning design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:32:53 +01:00
3a75269323 fix(composant) : replace unique constraint from name to reference validation
Remove DB unique index on composants.name and add Symfony UniqueEntity
validation on reference field with explicit error message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:12:19 +01:00
Matthieu
66fa0a506c docs : update CLAUDE.md with project conventions and architecture
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 18:00:53 +01:00
Matthieu
9b35023879 chore(release) : bump version to 1.9.4 + update frontend pointer (detail views)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 10:15:30 +01:00
Matthieu
5463cde38b chore(submodule) : update frontend pointer (machine history section)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:38:09 +01:00
Matthieu
7eb6def192 chore(submodule) : update frontend pointer (display ref in selects)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:21:46 +01:00
Matthieu
9d75653624 chore(submodule) : update frontend pointer (fix server-search client filter)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:19:33 +01:00
Matthieu
fd69d6a63e chore(submodule) : update frontend pointer (select search on name + reference)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:12:07 +01:00
Matthieu
172ec78c5f chore(submodule) : update frontend pointer (multi-field search)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:57:34 +01:00
Matthieu
d70b9086d5 feat(search) : add MultiSearchFilter for OR search on name + reference
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:57:30 +01:00
Matthieu
73ebd6902d chore(release) : bump version to 1.9.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:17:39 +01:00
Matthieu
ded1f7a8b6 chore(submodule) : update frontend pointer (comment attachments + fixes)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:10:41 +01:00
Matthieu
3b35598b07 fix(structure) : stabilize piece/component/product ordering in machines
All findBy(['machine' => ...]) queries now sort by createdAt ASC.
Without explicit ORDER BY, PostgreSQL returned rows in heap order which
changed on every INSERT, causing the displayed order to shuffle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:02:36 +01:00
Matthieu
06ce9fb1f2 chore(config) : update reference.php + remove disabled config files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:49:59 +01:00
Matthieu
8851f22e4e feat(ops) : add custom field audit and restore commands
- app:check-missing-custom-field-values — diagnostic des valeurs perdues
- app:restore-piece-custom-field-values — restauration générale
- app:restore-recoverable-piece-custom-field-values — restauration ciblée
  depuis l'audit log (dry-run par défaut, --apply pour exécuter)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:49:53 +01:00
Matthieu
330b9376f6 feat(comments) : add file attachments on comments
Comments can now have documents attached via multipart/form-data upload.
New endpoint GET /api/documents/comment/{id} to list a comment's files.
Document entity gains a comment relation with cascade remove.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:49:46 +01:00
Matthieu
4468fd7cdf fix(custom-fields) : match by orderIndex to prevent value loss on rename
When a ModelType's custom field was renamed without sending the field ID,
the service would create a new CustomField instead of reusing the existing
one, orphaning all CustomFieldValues. Now matches by orderIndex as fallback
before name, preserving the link to existing values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:36:59 +01:00
Matthieu
509c4d2247 test(data-integrity) : add 10 tests for data loss prevention
Tests cover:
- Clone: CustomFieldValue references cloned definitions, not source
- Clone: values are preserved after cloning
- Slots: 404 on non-existent piece, 422 on wrong type, success on correct type
- Conversion: blocked when slots have filled data, allowed when empty
- CustomField: rejects orphan creation, accepts existing field by ID

Also removes legacy JSON structure check (column no longer exists
after normalization) — replaced by slot table checks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:33:18 +01:00
Matthieu
043f6b1ce6 fix(data-integrity) : prevent data loss in clone, slots, conversion and custom fields
- Clone: CustomFieldValue now references cloned CustomField, not source
- Slots: validate piece type matches slot requirement + 404 on missing piece
- Conversion: check slot tables before allowing category conversion + clean orphan skeleton requirements
- CustomFieldValue: prevent creation of orphan CustomField without target entity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:15:05 +01:00
Matthieu
d5a43fc9bb chore(submodule) : update frontend pointer (changelog v1.10.0)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:14:25 +01:00
Matthieu
0de2aba538 chore(submodule) : update frontend pointer (document types feature)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:57:25 +01:00
Matthieu
5ec6e49af2 feat(documents) : accept type on upload + expose in query controller + PATCH tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:20:39 +01:00
Matthieu
8d920d5f65 feat(documents) : add migration for type column with data classification
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:14:33 +01:00
Matthieu
342b0afdbb feat(documents) : add DocumentType enum and type column on entity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:09:49 +01:00
Matthieu
2043e5b643 fix(constructeurs) : persist supplier removal on Piece, Composant and Product
- Frontend: always include constructeurs key in PATCH payload even when
  empty, so merge-patch+json actually clears the relation
- Backend: add setConstructeurs() on Piece and Product entities (Composant
  already had it) so API Platform can replace the ManyToMany collection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:54:08 +01:00
Matthieu
21e5ad5381 chore(submodule) : update frontend pointer (redirect to edit after creation)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:30:18 +01:00
Matthieu
53b6abc9a8 fix(composant) : persist piece/product/subcomponent selections on creation
The ComposantProcessor now reads the structure payload from the frontend
and applies selectedPieceId/selectedProductId/selectedComponentId to the
scaffolded slots, so user selections are actually saved to the database.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:04:19 +01:00
Matthieu
826dae7712 fix(composant) : scaffold skeleton slots on creation + explicit unique constraint errors
- Add ComposantProcessor: initializes piece/product/subcomponent slots
  from ModelType skeleton requirements when a composant is created
- UniqueConstraintSubscriber: priority 256, French error messages,
  constraint name detection for specific feedback
- Migration: scaffold missing slots for existing composants in prod

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:48:23 +01:00
Matthieu
38777b7de0 fix(custom-fields) : prevent data loss on ModelType save + restoration scripts
Backend: match existing CustomField by name as fallback when ID is not provided,
preventing deletion and recreation of field definitions (which cascade-deletes values).

Includes restoration/migration scripts for prod:
- restore-custom-field-values.php: restores piece values from audit logs
- migrate-orphaned-custom-fields.php: migrates values from orphaned CFs
- fix-prod-all.php: combined fix (migrate + restore + cleanup)
- fix-prod-recreate-and-migrate.php: full fix (recreate missing CFs + migrate + restore)
- check-prod-*.php: diagnostic scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:24:37 +01:00
Matthieu
add3a9a21f fix(mcp) : return CallToolResult to prevent structuredContent serialization issue
Tools now return CallToolResult directly instead of Content arrays,
preventing the MCP SDK from auto-generating structuredContent as a
JSON array (which Claude Code rejects — expects a JSON object/record).
Also adds Accept header to test helpers and SSE response parsing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 17:24:04 +01:00
Matthieu
f965affc94 feat(mcp) : add MCP resources, documentation, and .mcp.json config
- 3 MCP resources: schema, roles, stats
- docs/mcp/README.md with full user guide (config, tools catalogue, workflows)
- .mcp.json for Claude Code stdio transport
- Design spec and implementation plan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:49:00 +01:00
Matthieu
4340a0e13e feat(mcp) : add business tools — search, history, comments, custom fields, documents, model types
- search_inventory: global search across all 6 entity types
- get_entity_history + get_activity_log: audit trail access
- 4 comment tools: list, create, resolve, unresolved count
- 3 custom field tools: list values, upsert, delete
- 2 document tools: list, delete (upload via REST only)
- 6 model type tools: list, get, create, update, delete, sync
- 69 MCP tests pass total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:00:37 +01:00
Matthieu
bd7259ed05 feat(mcp) : add Slots, Machine Links, Structure, and Clone tools
- list_slots + update_slots for composant/piece slots
- list/add/update/remove machine links (component, piece, product)
- get_machine_structure with full hierarchy
- clone_machine with all links and custom fields
- 52 MCP tests pass total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:49:55 +01:00
Matthieu
2f173e766d feat(mcp) : add CRUD tools for Pieces, Composants, Machines
- 5 tools each: list, get, create, update, delete
- Piece: includes typePiece, constructeurs, prix (string)
- Composant: includes typeComposant, constructeurs, prix (string)
- Machine: includes site (required), constructeurs
- 40 MCP tests pass total

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:38:55 +01:00
Matthieu
4f1e136dc5 feat(mcp) : add CRUD tools for Sites, Constructeurs, Products
- 5 tools each: list, get, create, update, delete
- McpToolHelper extracted to AbstractApiTestCase for reuse
- DashboardStatsToolTest simplified to use base helpers
- 22 MCP tests pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:31:15 +01:00
Matthieu
e335f4c24c feat(mcp) : add stdio auth, dashboard stats PoC tool, and helper trait
- McpStdioAuthSubscriber for console transport auth via env vars
- DashboardStatsTool as PoC (validates MCP protocol flow)
- McpToolHelper trait for shared pagination/error utilities
- Key learning: #[McpTool] must be on CLASS, not method for __invoke

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:18:09 +01:00
Matthieu
46ea3ca8ad feat(mcp) : re-enable MCP bundle config after package install
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:09:29 +01:00
Matthieu
65fbd38b55 fix(config) : disable rate_limiter config requiring uninstalled component
The mcp_auth rate limiter requires symfony/rate-limiter which is not
installed. Renamed to .disabled until the MCP stack is ready.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:02:37 +01:00
Matthieu
37aa755819 fix(config) : disable uninstalled McpBundle to fix boot crash
McpBundle was registered but symfony/ai-mcp-bundle is not installed,
causing a critical error on boot. Disabled all MCP references:
- bundles.php: removed McpBundle
- mcp.yaml: renamed to mcp.yaml.disabled
- routes.yaml: removed mcp route
- services.yaml: commented McpHeaderAuthenticator, excluded src/Mcp/
- security.yaml: commented mcp firewall and access control
- phpunit.dist.xml: excluded tests/Mcp

All marked with TODO for re-enabling when the package is installed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:01:19 +01:00
Matthieu
98caaa148d feat(mcp) : add McpHeaderAuthenticator with rate limiting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:07:32 +01:00
Matthieu
523eed927e feat(mcp) : install symfony/mcp-bundle and configure transports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:02:15 +01:00
Matthieu
43bec07bb8 fix(sync) : preserve slot selections when modifying ModelType structure
SkeletonStructureService was deleting all skeleton requirements and
recreating them on every ModelType update. Combined with position-based
matching in sync strategies, any reordering or insertion caused all
existing slots to be orphaned and recreated empty, losing selections.

- SkeletonStructureService: update requirements in-place by matching
  on typeId instead of delete-all/recreate-all
- ComposantSyncStrategy & PieceSyncStrategy: two-pass smart matching
  algorithm (exact typeId+position first, then typeId-only fallback)
  to preserve selectedPiece/selectedComposant/selectedProduct on
  reorder/insertion
- Frontend: check patch result.success before updating local state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:32:14 +01:00
Matthieu
0181f18778 docs(submodule) : update frontend pointer with v1.9.1 changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:53:39 +01:00
Matthieu
8e0acf4896 chore(release) : bump version to 1.9.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:40:45 +01:00
Matthieu
aa8e043c83 fix(submodule) : update frontend pointer with slot selection cache fix
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:32:15 +01:00
218 changed files with 29560 additions and 2950 deletions

6
.claude/settings.json Normal file
View File

@@ -0,0 +1,6 @@
{
"enabledPlugins": {
"security-guidance@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true
}
}

0
.codex Normal file
View File

24
.dockerignore Normal file
View File

@@ -0,0 +1,24 @@
.git
.gitea
.env.local
.env.test
docker/
deploy/docker/docker-compose.prod.yml
deploy/docker/deploy.sh
deploy/docker/.env.example
Inventory_frontend/node_modules
Inventory_frontend/.nuxt
Inventory_frontend/.output
var/
vendor/
LOG/
docs/
tests/
scripts/
*.sql
*.xlsx
*.png
*.md
!composer.lock
!symfony.lock
!Inventory_frontend/package-lock.json

View File

@@ -0,0 +1,32 @@
name: Build & Push Docker Image
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
- name: Login to Gitea Registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.malio.fr -u "${{ gitea.repository_owner }}" --password-stdin
- name: Build Docker image
run: |
docker build \
-f deploy/docker/Dockerfile.prod \
-t gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }} \
-t gitea.malio.fr/malio-dev/inventory:latest \
.
- name: Push Docker image
run: |
docker push gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }}
docker push gitea.malio.fr/malio-dev/inventory:latest

9
.gitignore vendored
View File

@@ -1,4 +1,3 @@
###> symfony/framework-bundle ### ###> symfony/framework-bundle ###
/.env.local /.env.local
/.env.local.php /.env.local.php
@@ -23,19 +22,16 @@
###> docker ### ###> docker ###
docker/.env.docker.local docker/.env.docker.local
###< docker ### ###< docker ###
###> lexik/jwt-authentication-bundle ###
/config/jwt/*.pem
###< lexik/jwt-authentication-bundle ###
###> migration archives ### ###> migration archives ###
/_archives/ /_archives/
###< migration archives ### ###< migration archives ###
###> temp files ### ###> temp files ###
*.sql *.sql
*.sql.gz
*.har *.har
FEATURE_IDEAS.md FEATURE_IDEAS.md
bin/.phpunit.result.cache
###< temp files ### ###< temp files ###
###> frontend ### ###> frontend ###
@@ -49,3 +45,4 @@ FEATURE_IDEAS.md
###> wsl ### ###> wsl ###
*:Zone.Identifier *:Zone.Identifier
###< wsl ### ###< wsl ###
config/reference.php

12
.mcp.json Normal file
View File

@@ -0,0 +1,12 @@
{
"mcpServers": {
"inventory": {
"type": "http",
"url": "http://inventory.malio-dev.fr/_mcp",
"headers": {
"X-Profile-Id": "admin-default-profile",
"X-Profile-Password": "A123"
}
}
}
}

View File

@@ -18,13 +18,22 @@ Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
| Auth | Session-based (cookies, pas JWT) | | | Auth | Session-based (cookies, pas JWT) | |
| Containers | Docker Compose | | | Containers | Docker Compose | |
## Glossaire Métier
Voir `docs/GLOSSAIRE_METIER.md` — glossaire complet du domaine métier (concepts, workflows utilisateur, correspondance métier↔code). À consulter pour comprendre le "pourquoi" derrière le code.
## Project Structure ## Project Structure
``` ```
Inventory/ # Backend Symfony (repo principal) Inventory/ # Backend Symfony (repo principal)
├── src/Entity/ # Entités Doctrine (annotations PHP 8 attributes) ├── src/Entity/ # Entités Doctrine (annotations PHP 8 attributes)
│ └── Trait/ # CuidEntityTrait (génération d'ID CUID)
├── src/Controller/ # Controllers custom (session, comments, audit…) ├── src/Controller/ # Controllers custom (session, comments, audit…)
├── src/EventSubscriber/ # Audit subscribers (onFlush) ├── src/EventSubscriber/ # Audit subscribers (onFlush)
├── src/Service/ # Services métier (sync, conversion, storage…)
├── src/Enum/ # Enums PHP (DocumentType, ModelCategory)
├── src/DTO/ # Data Transfer Objects (sync workflow)
├── src/Filter/ # Filtres API Platform custom
├── src/Command/ # Commandes Symfony CLI (compress-pdf, create-profile…)
├── config/ # Config Symfony ├── config/ # Config Symfony
├── migrations/ # Migrations Doctrine (raw SQL PostgreSQL) ├── migrations/ # Migrations Doctrine (raw SQL PostgreSQL)
├── docker/ # Dockerfile + .env.docker ├── docker/ # Dockerfile + .env.docker
@@ -111,7 +120,8 @@ Le frontend est un submodule git. Lors d'un commit frontend :
#### Entités de normalisation (slots & skeleton requirements) #### Entités de normalisation (slots & skeleton requirements)
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles : Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
- **Slots** (données réelles d'un composant) : `ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot` - **Slots composant** (données réelles d'un composant) : `ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot`
- **Slots pièce** (données réelles d'une pièce) : `PieceProductSlot`
- **Skeleton Requirements** (définitions du ModelType) : `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` - **Skeleton Requirements** (définitions du ModelType) : `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
### Patterns ### Patterns
@@ -127,6 +137,8 @@ Remplacent les anciennes colonnes JSON `structure` et `productIds` par des table
- `MachineCustomFieldsController``/api/machines/{id}/add-custom-fields` (POST) : initialise les CustomFieldValue manquants pour une 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. - `CustomFieldValueController``/api/custom-fields/values/*` : CRUD + upsert pour les valeurs de champs perso.
- `ComposantPieceSlotController``/api/composant-piece-slots/{id}` (PATCH) : mise à jour des slots pièce d'un composant. - `ComposantPieceSlotController``/api/composant-piece-slots/{id}` (PATCH) : mise à jour des slots pièce d'un composant.
- `ComposantProductSlotController``/api/composant-product-slots/{id}` (PATCH) : mise à jour des slots produit d'un composant.
- `ComposantSubcomponentSlotController``/api/composant-subcomponent-slots/{id}` (PATCH) : mise à jour des slots sous-composant d'un composant.
- `SessionProfileController``/api/session/profile` (GET/POST/DELETE) : auth session (login/logout/current user). - `SessionProfileController``/api/session/profile` (GET/POST/DELETE) : auth session (login/logout/current user).
- `SessionProfilesController``/api/session/profiles` (GET) : liste des profils disponibles pour la session. - `SessionProfilesController``/api/session/profiles` (GET) : liste des profils disponibles pour la session.
- `AdminProfileController``/api/admin/profiles` : CRUD profils, gestion rôles et mots de passe (ROLE_ADMIN). - `AdminProfileController``/api/admin/profiles` : CRUD profils, gestion rôles et mots de passe (ROLE_ADMIN).
@@ -136,6 +148,8 @@ Remplacent les anciennes colonnes JSON `structure` et `productIds` par des table
- `DocumentQueryController``/api/documents/{entity}/{id}` (GET) : documents par site/machine/composant/pièce/produit. - `DocumentQueryController``/api/documents/{entity}/{id}` (GET) : documents par site/machine/composant/pièce/produit.
- `DocumentServeController``/api/documents/{id}/file|download` (GET) : servir/télécharger fichiers. - `DocumentServeController``/api/documents/{id}/file|download` (GET) : servir/télécharger fichiers.
- `ModelTypeConversionController``/api/model_types/{id}/conversion-check|convert` : vérification et conversion de ModelType. - `ModelTypeConversionController``/api/model_types/{id}/conversion-check|convert` : vérification et conversion de ModelType.
- `ModelTypeSyncController``/api/model_types/{id}/sync-preview|sync-confirm` (POST) : prévisualisation et application de sync ModelType→Composants.
- `EntityVersionController``/api/{entity}/{id}/versions` (GET), `/api/{entity}/{id}/versions/{version}/restore` (POST) : historique de versions numérotées et restauration.
- `HealthCheckController``/api/health` (GET) : health check. - `HealthCheckController``/api/health` (GET) : health check.
### Custom Fields — Architecture ### Custom Fields — Architecture
@@ -143,11 +157,30 @@ Remplacent les anciennes colonnes JSON `structure` et `productIds` par des table
- **Machines** : définitions = entités `CustomField` liées directement via `machineId` FK (pas de 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 - Les deux partagent la même entité `CustomFieldValue` pour stocker les valeurs
### Normalisation JSON → Tables (architecture slots) ### Enums (`src/Enum/`)
Les anciennes colonnes JSON `structure` et `productIds` des Composants ont été remplacées par des tables relationnelles : - `DocumentType` — types de documents (photo, schéma, facture, etc.)
- **ModelType** définit le squelette via `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` - `ModelCategory` — catégories de ModelType
- **Composant** stocke les données réelles via `ComposantPieceSlot`, `ComposantProductSlot`, `ComposantSubcomponentSlot`
- Chaque slot référence son skeleton requirement (`skeletonRequirement` FK) + l'entité sélectionnée + position ### Services (`src/Service/`)
- `ModelTypeSyncService` — synchronise les skeleton requirements d'un ModelType vers les composants existants
- `ModelTypeCategoryConversionService` — conversion de catégorie d'un ModelType
- `SkeletonStructureService` — gestion de la structure skeleton (requirements)
- `DocumentStorageService` — stockage et gestion des fichiers documents
- `PdfCompressorService` — compression des PDFs uploadés
- `EntityVersionService` — gestion des versions numérotées (snapshot, restore) pour machines, pièces, composants, produits
- `ReferenceAutoGenerator` — génération automatique de références pour pièces et composants à partir de formules ModelType
- `src/Service/Sync/` — stratégies de sync par type de slot (tagged `app.sync_strategy`)
### DTOs (`src/DTO/`)
- `SyncConfirmation`, `SyncPreviewResult`, `SyncExecutionResult` — objets de transfert pour le workflow de sync ModelType
### Filters (`src/Filter/`)
- `MultiSearchFilter` — filtre API Platform pour recherche OR sur plusieurs champs (ex: name + reference)
### EventSubscribers notables (non-audit)
- `PieceProductSyncSubscriber` — sync automatique des PieceProductSlots
- `UniqueConstraintSubscriber` — traduit les erreurs de contrainte unique PG en messages utilisateur lisibles
- `ReferenceAutoSubscriber` — recalcule les références auto des pièces/composants quand les CustomFieldValues changent (onFlush)
### Rôles (hiérarchie) ### Rôles (hiérarchie)
``` ```
@@ -223,7 +256,7 @@ make test-setup # Créer/mettre à jour le schéma test
### Pattern de test ### Pattern de test
- Hériter de `AbstractApiTestCase` (helpers auth + factories) - Hériter de `AbstractApiTestCase` (helpers auth + factories)
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback - Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()` - Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`, `createPieceProductSlot()`
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()` - Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
## URLs Locales ## URLs Locales

29
TODO.md Normal file
View File

@@ -0,0 +1,29 @@
# TODO — MCP Inventory
## Bugs / Améliorations prioritaires
### sync_model_type ne fonctionne pas via MCP
Le tool `sync_model_type` attend un paramètre `structure` de type `array` (objet JSON imbriqué), mais le SDK MCP PHP ne supporte pas les objets complexes en paramètres — il reçoit un string au lieu d'un array.
**Solutions possibles :**
1. Accepter `structure` comme `string` (JSON encodé) et le décoder manuellement dans le tool
2. Créer des tools séparés : `add_product_requirement`, `add_custom_field_requirement`, `remove_requirement` au lieu d'un seul sync
3. Passer par des sous-paramètres plats (productTypeIds, customFieldNames, etc.)
**Impact :** L'IA ne peut pas ajouter de produits ni de champs personnalisés à une catégorie (ModelType) via MCP. Contournement actuel : passer par l'API REST.
---
### Resources MCP en erreur
Les 3 Resources (`SchemaResource`, `RolesResource`, `StatsResource`) produisent `[error] Failed to process MCP attribute`. Elles ne bloquent pas les tools mais ne sont pas exposées aux clients.
**Cause probable :** Incompatibilité du format `#[McpResource]` avec le SDK v0.4 / bundle v0.6.
---
## Améliorations futures
- [ ] Documentation utilisateur `docs/mcp/README.md` — guide d'utilisation pour les différents clients (Claude Desktop, ChatGPT, Codex)
- [ ] Mettre à jour CLAUDE.md avec la section MCP
- [ ] Ajouter le tool `upload_document` (upload de fichiers via MCP)
- [ ] Tester la compatibilité avec ChatGPT Desktop et Claude Desktop via tunnel

View File

@@ -1 +1 @@
1.9.0 1.9.4

View File

@@ -12,8 +12,8 @@
"doctrine/doctrine-bundle": "^3.2", "doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6", "doctrine/orm": "^3.6",
"lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6", "nelmio/cors-bundle": "^2.6",
"nyholm/psr7": "^1.8",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3", "phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "8.0.*", "symfony/asset": "8.0.*",
@@ -22,16 +22,17 @@
"symfony/expression-language": "8.0.*", "symfony/expression-language": "8.0.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/framework-bundle": "8.0.*", "symfony/framework-bundle": "8.0.*",
"symfony/mcp-bundle": "^0.6.0",
"symfony/property-access": "8.0.*", "symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*", "symfony/property-info": "8.0.*",
"symfony/rate-limiter": "8.0.*",
"symfony/runtime": "8.0.*", "symfony/runtime": "8.0.*",
"symfony/security-bundle": "8.0.*", "symfony/security-bundle": "8.0.*",
"symfony/serializer": "8.0.*", "symfony/serializer": "8.0.*",
"symfony/twig-bundle": "8.0.*", "symfony/twig-bundle": "8.0.*",
"symfony/uid": "8.0.*", "symfony/uid": "8.0.*",
"symfony/validator": "8.0.*", "symfony/validator": "8.0.*",
"symfony/yaml": "8.0.*", "symfony/yaml": "8.0.*"
"vich/uploader-bundle": "^2.9"
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {

1417
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,8 @@ use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle; use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle; use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle; use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\AI\McpBundle\McpBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Bundle\TwigBundle\TwigBundle;
@@ -20,6 +20,6 @@ return [
DoctrineMigrationsBundle::class => ['all' => true], DoctrineMigrationsBundle::class => ['all' => true],
NelmioCorsBundle::class => ['all' => true], NelmioCorsBundle::class => ['all' => true],
ApiPlatformBundle::class => ['all' => true], ApiPlatformBundle::class => ['all' => true],
LexikJWTAuthenticationBundle::class => ['all' => true],
DAMADoctrineTestBundle::class => ['test' => true], DAMADoctrineTestBundle::class => ['test' => true],
McpBundle::class => ['all' => true],
]; ];

View File

@@ -1,7 +1,7 @@
api_platform: api_platform:
title: Inventory API title: Inventory API
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits. description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
version: 1.8.1 version: 1.9.1
defaults: defaults:
stateless: false stateless: false
cache_headers: cache_headers:

View File

@@ -3,7 +3,10 @@ framework:
secret: '%env(APP_SECRET)%' secret: '%env(APP_SECRET)%'
# Note that the session will be started ONLY if you read or write from it. # Note that the session will be started ONLY if you read or write from it.
session: true session:
cookie_secure: auto
cookie_samesite: lax
cookie_httponly: true
#esi: true #esi: true
#fragments: true #fragments: true

View File

@@ -0,0 +1,10 @@
services:
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
http_discovery.psr17_factory:
class: Http\Discovery\Psr17Factory

View File

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

20
config/packages/mcp.yaml Normal file
View File

@@ -0,0 +1,20 @@
mcp:
app: 'inventory'
version: '1.0.0'
description: 'Inventory MCP Server - Gestion inventaire industriel (machines, pièces, composants, produits)'
instructions: |
Serveur MCP pour gérer un inventaire industriel.
Entités principales : Machine, Composant, Pièce, Produit, Site, Constructeur.
Utilisez search_inventory pour chercher dans toutes les entités.
Utilisez get_model_type pour comprendre la structure attendue avant de créer un composant ou une pièce.
Consultez la resource inventory://schema/entities pour voir le schéma complet.
Authentification requise : envoyez X-Profile-Id et X-Profile-Password dans les headers HTTP.
client_transports:
stdio: true
http: true
http:
path: /_mcp
session:
store: file
directory: '%kernel.cache_dir%/mcp-sessions'
ttl: 3600

View File

@@ -0,0 +1,10 @@
framework:
rate_limiter:
mcp_auth:
policy: sliding_window
limit: 5
interval: '1 minute'
login:
policy: sliding_window
limit: 5
interval: '1 minute'

View File

@@ -27,6 +27,12 @@ security:
pattern: ^/api/session/profiles?$ pattern: ^/api/session/profiles?$
security: false security: false
mcp:
pattern: ^/_mcp
stateless: true
custom_authenticators:
- App\Mcp\Security\McpHeaderAuthenticator
api: api:
pattern: ^/api pattern: ^/api
stateless: false stateless: false
@@ -49,6 +55,7 @@ security:
- { path: ^/api/admin, roles: ROLE_ADMIN } - { path: ^/api/admin, roles: ROLE_ADMIN }
- { path: ^/api/docs, roles: PUBLIC_ACCESS } - { path: ^/api/docs, roles: PUBLIC_ACCESS }
- { path: ^/api/health$, roles: PUBLIC_ACCESS } - { path: ^/api/health$, roles: PUBLIC_ACCESS }
- { path: ^/_mcp, roles: ROLE_USER }
- { path: ^/docs, roles: PUBLIC_ACCESS } - { path: ^/docs, roles: PUBLIC_ACCESS }
- { path: ^/contexts, roles: PUBLIC_ACCESS } - { path: ^/contexts, roles: PUBLIC_ACCESS }
- { path: ^/\.well-known, roles: PUBLIC_ACCESS } - { path: ^/\.well-known, roles: PUBLIC_ACCESS }

File diff suppressed because it is too large Load Diff

View File

@@ -12,3 +12,7 @@ api_login_check:
controllers: controllers:
resource: routing.controllers resource: routing.controllers
mcp:
resource: .
type: mcp

View File

@@ -34,6 +34,10 @@ services:
tags: tags:
- { name: doctrine.event_subscriber } - { name: doctrine.event_subscriber }
App\Mcp\Security\McpHeaderAuthenticator:
arguments:
$mcpAuthLimiter: '@limiter.mcp_auth'
App\OpenApi\OpenApiDecorator: App\OpenApi\OpenApiDecorator:
decorates: 'api_platform.openapi.factory' decorates: 'api_platform.openapi.factory'
arguments: arguments:
@@ -60,3 +64,8 @@ when@test:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
public: true public: true
App\Service\ReferenceAutoGenerator:
autowire: true
autoconfigure: true
public: true

View File

@@ -0,0 +1,10 @@
# Symfony
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=change-me
# Database (use host.docker.internal to reach bare-metal PostgreSQL)
DATABASE_URL="postgresql://inventory_user:password@host.docker.internal:5432/inventory_prod?serverVersion=16&charset=utf8"
# CORS
CORS_ALLOW_ORIGIN='^https?://inventory\.malio-dev\.fr$'

View File

@@ -0,0 +1,83 @@
# --- Stage 1: Build backend ---
FROM php:8.4-cli AS backend-build
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
unzip curl git \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN APP_ENV=prod APP_DEBUG=0 composer install --no-dev --no-scripts --no-interaction
COPY bin bin/
COPY config config/
COPY migrations migrations/
COPY public public/
COPY src src/
COPY templates templates/
COPY VERSION VERSION
RUN composer dump-autoload --optimize --no-dev
# --- Stage 2: Build frontend ---
FROM node:lts-alpine AS frontend-build
WORKDIR /app/Inventory_frontend
COPY Inventory_frontend/package.json Inventory_frontend/package-lock.json ./
RUN npm ci
COPY Inventory_frontend/ ./
ENV CI=1 \
NUXT_TELEMETRY_DISABLED=1 \
NUXT_PUBLIC_API_BASE_URL=/api \
NUXT_PUBLIC_APP_BASE=/
RUN npm run generate
# --- Stage 3: Production image ---
FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
# PHP production config
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# PHP-FPM: forward worker output to stderr for docker logs
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \
&& echo "decorate_workers_output = no" >> /usr/local/etc/php-fpm.d/www.conf
# Nginx: log to stdout/stderr
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
&& ln -sf /dev/stderr /var/log/nginx/error.log
# Remove default nginx site
RUN rm -f /etc/nginx/sites-enabled/default
# Configs
COPY deploy/docker/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY deploy/docker/nginx.conf /etc/nginx/sites-enabled/inventory.conf
# Backend from stage 1
COPY --from=backend-build /app /var/www/html
# Frontend from stage 2
COPY --from=frontend-build /app/Inventory_frontend/.output/public /var/www/html/Inventory_frontend/.output/public
# Symfony needs a .env file to boot (variables are overridden by env_file in docker-compose)
RUN echo "APP_ENV=prod" > /var/www/html/.env
# Permissions
RUN mkdir -p /var/www/html/var /var/www/html/var/uploads \
&& chown -R www-data:www-data /var/www/html/var
WORKDIR /var/www/html
EXPOSE 80
CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/app.conf"]

28
deploy/docker/deploy.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
TAG="${1:-latest}"
export INVENTORY_IMAGE_TAG="$TAG"
echo "==> Deploying inventory:${TAG}..."
echo "==> Pulling image..."
sudo docker compose pull
echo "==> Starting container..."
sudo docker compose up -d
echo "==> Waiting for container to be ready..."
sleep 3
echo "==> Running migrations..."
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Clearing cache..."
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
VERSION=$(sudo docker compose exec -T app cat VERSION)
echo "==> Deployed v${VERSION}"

View File

@@ -0,0 +1,12 @@
services:
app:
image: gitea.malio.fr/malio-dev/inventory:${INVENTORY_IMAGE_TAG:-latest}
container_name: inventory-app
env_file: .env
ports:
- "8082:80"
volumes:
- ./uploads:/var/www/html/var/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped

36
deploy/docker/nginx.conf Normal file
View File

@@ -0,0 +1,36 @@
server {
listen 80;
server_name _;
root /var/www/html/Inventory_frontend/.output/public;
index index.html;
access_log /dev/stdout;
error_log /dev/stderr;
location ^~ /api/ {
root /var/www/html/public;
try_files $uri /index.php?$query_string;
}
location ^~ /bundles/ {
root /var/www/html/public;
try_files $uri =404;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
fastcgi_pass 127.0.0.1:9000;
internal;
}
location ~ \.php$ {
return 404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,28 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid
[program:php-fpm]
command=php-fpm -F
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopasgroup=true
stopsignal=QUIT
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopasgroup=true
stopsignal=QUIT

View File

@@ -0,0 +1,13 @@
server {
listen 80;
listen [::]:80;
server_name inventory.malio-dev.fr;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

299
doc/deployment-docker.md Normal file
View File

@@ -0,0 +1,299 @@
# Deploiement Docker — Inventory
## Pre-requis
### Docker
```bash
# Ubuntu
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker $USER
```
Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
### Nginx
```bash
sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx
```
### PostgreSQL
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
Il doit etre installe et accessible avant de deployer Inventory.
Creer la base de donnees pour Inventory :
```bash
cd /var/www/postgres
docker compose exec postgres psql -U admin
```
```sql
-- Si le user n'existe pas encore
CREATE USER malio WITH PASSWORD 'motdepasse';
-- Creer la base
CREATE DATABASE inventory_prod OWNER malio;
\q
```
---
## Premiere installation (nouvelle machine)
Guide complet pour mettre en ligne Inventory sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
### 1. Installer les pre-requis
Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
### 2. Creer le dossier de deploiement
```bash
sudo mkdir -p /var/www/inventory
sudo chown -R $(whoami):$(whoami) /var/www/inventory
cd /var/www/inventory
```
### 3. Se connecter au registry Docker de Gitea
```bash
docker login gitea.malio.fr
```
- **Username** : le nom d'utilisateur du compte organisation Gitea `MALIO`
- **Password** : le token REGISTRY_TOKEN dispo dans le bitwarden
Le login est sauvegarde dans `~/.docker/config.json`, pas besoin de le refaire a chaque deploiement.
### 4. Creer les fichiers de deploiement
Creer `docker-compose.yml` :
```yaml
services:
app:
image: gitea.malio.fr/malio-dev/inventory:${INVENTORY_IMAGE_TAG:-latest}
container_name: inventory-app
env_file: .env
ports:
- "8080:80"
volumes:
- ./uploads:/var/www/html/var/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
```
Creer `deploy.sh` :
```bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
TAG="${1:-latest}"
export INVENTORY_IMAGE_TAG="$TAG"
echo "==> Deploying inventory:${TAG}..."
echo "==> Pulling image..."
docker compose pull
echo "==> Starting container..."
docker compose up -d
echo "==> Waiting for container to be ready..."
sleep 3
echo "==> Running migrations..."
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Clearing cache..."
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
VERSION=$(docker compose exec -T app cat VERSION)
echo "==> Deployed v${VERSION}"
```
Rendre executable :
```bash
chmod +x deploy.sh
```
### 5. Configurer l'environnement
Creer `.env` avec les variables suivantes :
```env
# Symfony
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=<generer avec: openssl rand -hex 32>
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/inventory_prod?serverVersion=16&charset=utf8"
# CORS
CORS_ALLOW_ORIGIN='^https?://inventory\.malio-dev\.fr$'
```
### 6. Creer le dossier uploads
```bash
mkdir -p uploads
```
### 7. Configurer Nginx systeme
Creer `/etc/nginx/sites-available/inventory.conf` :
```nginx
server {
listen 80;
server_name inventory.malio-dev.fr;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Activer le site :
```bash
sudo ln -sf /etc/nginx/sites-available/inventory.conf /etc/nginx/sites-enabled/inventory.conf
sudo nginx -t && sudo systemctl reload nginx
```
### 8. Deployer
```bash
./deploy.sh
```
### 9. Importer les donnees (optionnel)
Si tu as un dump SQL a importer :
```bash
# Depuis ton PC, envoyer le dump vers le serveur
scp inventory.sql user@serveur:/tmp/inventory.sql
# Sur le serveur, vider la base puis importer
cd /var/www/postgres
docker compose exec -T postgres psql -U malio inventory_prod -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose exec -T postgres psql -U malio inventory_prod < /tmp/inventory.sql
# Creer les tables manquantes (si le dump a des erreurs de syntaxe)
cd /var/www/inventory
docker compose exec -u www-data app php bin/console doctrine:schema:update --force --env=prod
# Nettoyer
rm /tmp/inventory.sql
```
### Structure finale du dossier
```
/var/www/inventory/
├── docker-compose.yml
├── deploy.sh
├── .env
└── uploads/
```
---
## Deployer une nouvelle version
Quand l'app est deja installee, deployer une mise a jour :
```bash
cd /var/www/inventory
./deploy.sh # deploie la derniere version (latest)
./deploy.sh v1.9.4 # deploie une version specifique
```
C'est tout. Le script pull l'image, redemarre le conteneur, lance les migrations et vide le cache.
---
## Rollback
### Image seule (pas de changement de schema BDD)
```bash
./deploy.sh v1.9.3
```
### Avec rollback de migration
```bash
# 1. Rollback schema (pendant que la version actuelle tourne encore)
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate prev --no-interaction
# 2. Deployer l'ancienne version
./deploy.sh v1.9.3
```
---
## CI/CD
Le workflow `.gitea/workflows/build-docker.yml` se declenche automatiquement sur push de tag `v*` :
1. Build l'image multi-stage (inclut checkout des submodules pour le frontend)
2. Push vers `gitea.malio.fr/malio-dev/inventory:<tag>` et `:latest`
---
## Voir les logs
```bash
cd /var/www/inventory
docker compose logs -f # tous les logs
docker compose logs -f --tail=100 # 100 dernieres lignes
```
Logs Symfony :
```bash
docker compose exec app cat var/log/prod.log
```
---
## Migration depuis l'ancien deploiement (bare-metal)
Si l'application tourne deja en bare metal :
1. Installer Docker (voir pre-requis)
2. Creer le dossier `/var/www/inventory-docker/` (ne pas ecraser l'ancien)
3. Copier les fichiers existants :
```bash
cp /var/www/inventory/.env /var/www/inventory-docker/.env
cp -a /var/www/inventory/var/uploads /var/www/inventory-docker/uploads
```
4. Creer `docker-compose.yml` et `deploy.sh` dans `/var/www/inventory-docker/` (voir etape 4 ci-dessus)
5. Editer `/var/www/inventory-docker/.env` : changer `DATABASE_URL` pour utiliser `host.docker.internal` au lieu de `127.0.0.1`
6. Se connecter au registry Gitea (voir etape 3 ci-dessus)
7. Mettre a jour Nginx systeme avec la conf reverse proxy (voir etape 7 ci-dessus)
8. Arreter l'ancien PHP-FPM/Apache : `sudo systemctl stop php8.4-fpm` ou `sudo systemctl stop apache2`
9. Deployer : `cd /var/www/inventory-docker && ./deploy.sh`
10. Verifier que tout marche, puis renommer le dossier : `mv /var/www/inventory-docker /var/www/inventory`

View File

@@ -0,0 +1,278 @@
# Champs Personnalises - Diagnostic Et Recuperation
Date : 2026-03-23
---
## Contexte
Un bug sur la sauvegarde des categories (`ModelType`) pouvait recreer des definitions de champs personnalises avec de nouveaux IDs.
Effet de bord :
- les `CustomFieldValue` existants restaient lies aux anciens `CustomField`
- puis etaient supprimes en cascade
- resultat visible : apres modification d'une categorie, certaines valeurs de champs perso disparaissaient
Le correctif preventif a ete fait :
- conservation des `id/customFieldId` cote frontend pour `PIECE/PRODUCT`
- matching backend plus robuste sur `id`, puis `orderIndex`, puis nom
Ce document couvre uniquement :
- comment detecter ce qui manque
- comment lire le listing
- comment identifier ce qui est recuperable depuis l'audit
- comment restaurer proprement
---
## Commandes Disponibles
### 1. Lister tous les champs perso manquants ou vides
Dans le conteneur :
```bash
php bin/console app:check-missing-custom-field-values
```
Variantes utiles :
```bash
php bin/console app:check-missing-custom-field-values --entity=piece
php bin/console app:check-missing-custom-field-values --entity=composant
php bin/console app:check-missing-custom-field-values --max-rows=1000
php bin/console app:check-missing-custom-field-values --limit=500 --max-rows=1000
```
### 2. Afficher uniquement les cas recuperables depuis l'audit
```bash
php bin/console app:check-missing-custom-field-values --recoverable-only
```
Variantes :
```bash
php bin/console app:check-missing-custom-field-values --entity=piece --recoverable-only
php bin/console app:check-missing-custom-field-values --entity=composant --recoverable-only
php bin/console app:check-missing-custom-field-values --recoverable-only --max-rows=1000
```
### 3. Dry-run de restauration pour une piece
```bash
php bin/console app:restore-piece-custom-field-values <pieceId>
```
Exemple :
```bash
php bin/console app:restore-piece-custom-field-values cl731386df55fcb9e6a01e0a63
```
### 4. Appliquer la restauration pour une piece
```bash
php bin/console app:restore-piece-custom-field-values <pieceId> --apply
```
---
## Colonnes Du Listing
La commande `app:check-missing-custom-field-values` affiche :
- `Entity` : `piece` ou `composant`
- `ID` : identifiant de l'entite
- `Name` : nom de l'entite
- `Reference` : reference metier si presente
- `Category` : nom de la categorie (`ModelType`)
- `Field` : nom du champ personnalise attendu par la categorie
- `Issue` : `missing` ou `empty`
- `Recoverable` : `yes` ou `no`
- `Audit value` : derniere valeur non vide retrouvee dans l'audit si disponible
---
## Signification Des Statuts
### `missing`
Il n'existe actuellement **aucune** ligne `CustomFieldValue` pour ce champ sur l'entite.
Cela peut vouloir dire :
- la valeur n'a jamais ete saisie
- la valeur a ete perdue lors du bug
- le champ a ete ajoute plus tard sur la categorie sans initialisation des anciennes entites
### `empty`
La ligne `CustomFieldValue` existe, mais sa valeur est vide.
Cela est plus suspect qu'un `missing`, mais ne prouve pas a lui seul une perte.
### `Recoverable = yes`
L'audit contient au moins une ancienne valeur non vide pour ce champ.
En pratique :
- c'est le signal le plus utile
- ce sont les cas a traiter en priorite
- ces cas sont potentiellement restaurables automatiquement
### `Recoverable = no`
L'audit de cette entite ne contient pas de valeur non vide exploitable pour ce champ.
Cela ne veut **pas** forcement dire qu'il n'y a jamais eu de valeur.
Cela veut simplement dire :
- rien de recuperable n'a ete trouve dans les logs d'audit consultes
---
## Lecture Des Cas Typiques
### Cas 1
```text
piece ... Roulement ... Diametre ... missing ... no
```
Interpretation :
- le champ `Diametre` est attendu sur cette piece
- aucune valeur n'existe actuellement
- l'audit ne permet pas de retrouver une ancienne valeur
Conclusion :
- non recuperable automatiquement
- a verifier metierement si la valeur a deja existe ou non
### Cas 2
```text
piece ... Arbre ... Diametre ... empty ... yes ... 35 mm
```
Interpretation :
- une ligne de valeur existe mais elle est vide
- l'audit montre qu'une ancienne valeur `35 mm` existait
Conclusion :
- cas typique de restauration automatique possible
### Cas 3
```text
piece ... Joint ... Matiere ... missing ... yes ... NBR
```
Interpretation :
- la valeur n'existe plus du tout
- l'audit permet de retrouver `NBR`
Conclusion :
- forte probabilite de perte historique
- recuperable automatiquement
---
## Priorisation Recommandee
Ordre de traitement conseille :
1. `empty + yes`
2. `missing + yes`
3. `empty + no`
4. `missing + no`
Pourquoi :
- les `yes` sont les seuls cas recuperables automatiquement
- les `empty` indiquent souvent une valeur ecrasee
- les `missing no` sont nombreux mais souvent ambigus
---
## Procedure Recommandee
### Etape 1 - Scanner globalement
```bash
php bin/console app:check-missing-custom-field-values --recoverable-only --max-rows=1000
```
### Etape 2 - Identifier les pieces prioritaires
Chercher :
- les pieces les plus critiques metierement
- les categories fortement touchees (`Roulement`, `Joint`, `Arbre`, etc.)
- les cas avec valeur d'audit explicite
### Etape 3 - Faire un dry-run piece par piece
```bash
php bin/console app:restore-piece-custom-field-values <pieceId>
```
### Etape 4 - Appliquer uniquement apres verification
```bash
php bin/console app:restore-piece-custom-field-values <pieceId> --apply
```
---
## Limites Actuelles
### Ce qui est pris en charge
- diagnostic global sur les `pieces`
- diagnostic global sur les `composants`
- restauration automatique ciblee sur les `pieces`
### Ce qui n'est pas encore automatise
- restauration automatique en masse
- restauration automatique des `composants`
- reconstitution si l'audit ne contient aucune ancienne valeur exploitable
---
## Interpretation Metier
Le listing global ne doit pas etre lu comme :
> "866 valeurs ont ete perdues"
Il doit etre lu comme :
> "866 couples entite/champ sont actuellement manquants ou vides par rapport aux definitions de categories"
Parmi eux :
- certains n'ont jamais ete renseignes
- certains ont probablement ete perdus
- seuls les cas `Recoverable = yes` sont candidates a une recuperation automatique fiable
---
## Commandes Resumees
```bash
# Tout lister
php bin/console app:check-missing-custom-field-values
# Afficher uniquement les cas recuperables
php bin/console app:check-missing-custom-field-values --recoverable-only
# Scanner seulement les pieces
php bin/console app:check-missing-custom-field-values --entity=piece --recoverable-only
# Scanner seulement les composants
php bin/console app:check-missing-custom-field-values --entity=composant --recoverable-only
# Dry-run de restauration d'une piece
php bin/console app:restore-piece-custom-field-values <pieceId>
# Application reelle
php bin/console app:restore-piece-custom-field-values <pieceId> --apply
```

View File

@@ -0,0 +1,144 @@
# Resultats Recuperables - Champs Personnalises
Date : 2026-03-23
Source : `php bin/console app:check-missing-custom-field-values --recoverable-only`
---
## Resume
- Total : 40 cas recuperables
- Pieces : 40
- Composants : 0
- Type de probleme observe : uniquement `empty`
- Categorie dominante : `Arbre`
- Champ le plus frequent : `Diamètre`
Conclusion :
- il n'y a pas ici une grande dispersion de cas heterogenes
- la quasi-totalite du lot correspond a des valeurs historisees recuperables sur des pieces de categorie `Arbre`
- ces cas sont de bons candidats a une restauration automatique
---
## Tableau
| Entity | ID | Name | Reference | Category | Field | Issue | Recoverable | Audit value |
|---|---|---|---|---|---|---|---|---|
| piece | `clc08fbdcd334ed869772d98ee` | Arbre de la cage écureuil pied E4 | | Arbre | Diamètre | empty | yes | 45 mm |
| piece | `cl8570d729efd017c12a2d5c3d` | Arbre du tambour tête E7 | | Arbre | Diamètre | empty | yes | 40 mm |
| piece | `cle1db7051dbef91fc009073a6` | Arbre de la cage écureuil pied E6 | | Arbre | Diamètre | empty | yes | 45 mm |
| piece | `cl9282d473ff01b5d1df8bc945` | Arbre E1 | | Arbre | Diamètre | empty | yes | 50 |
| piece | `cl22e81a055f9c393d8d2c82fc` | Arbre du palier pied E3 | | Arbre | Diamètre | empty | yes | 50 mm |
| piece | `clca9379d4aa76de6772ebbe1a` | Arbre pignon | `0-5720-00` | Arbre | Type | empty | yes | 20 DTS |
| piece | `clc97804ec0bf8b6d9bb530717` | Arbre du palier tête E2 E2B | | Arbre | Diamètre | empty | yes | 40 |
| piece | `cl1597f1500c1052e9e7a95c51` | Arbre du palier pied E2 E2B | | Arbre | Diamètre | empty | yes | 35 mm |
| piece | `cleea7ff4b9b1a6396a0bb9ea8` | Arbre du tambour tête E1 | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cl5c71e3777146de5508e07156` | Arbre de la cage écureuil pied E1 | | Arbre | Diamètre | empty | yes | 50 mm |
| piece | `cl731386df55fcb9e6a01e0a63` | Arbre de la cage écureuil pied E2 E2B | | Arbre | Diamètre | empty | yes | 35 mm |
| piece | `clfaf128312d5c253d928f47ac` | Arbre du palier pied E4 | | Arbre | Diamètre | empty | yes | 45 mm |
| piece | `clbf9f0070ebd464b3c309c646` | Arbre du palier pied E8 | | Arbre | Diamètre | empty | yes | 50 mm |
| piece | `clc7c00cad416477d4438cd61a` | Arbre du tambour tête E8 | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cl3f01a1a514423359405a4825` | Arbre du palier tête E7 | | Arbre | Diamètre | empty | yes | 40 mm |
| piece | `clf16e543545eddd01b20077df` | Arbre du tambour tête E5 | | Arbre | Diamètre | empty | yes | 55 mm |
| piece | `clb6c61ebb8da2c4361265f766` | Arbre du palier tête E6 | | Arbre | Diamètre | empty | yes | 55 mm |
| piece | `cl8da1b875191c617e5852bf81` | Arbre du tambour tête E2 E2B | | Arbre | Diamètre | empty | yes | 40 mm |
| piece | `cl8da1b875191c617e5852bf81` | Arbre du tambour tête E2 E2B | | Arbre | Diamètre palier | empty | yes | 40 |
| piece | `cla82d44c52d7eb2a592f4120d` | Arbre du palier pied E7 | | Arbre | Diamètre | empty | yes | 35 mm |
| piece | `clf8562d27a542f86f8f4a5629` | Arbre du palier tête E8 | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `clde7ee756c2cf264c062b861d` | Arbre du palier pied E6 | | Arbre | Diamètre | empty | yes | 45 mm |
| piece | `cl6667d159f6d07ba77fa79b39` | Arbre de la cage écureuil pied E5 | | Arbre | Diamètre | empty | yes | 45 mm |
| piece | `cl455ad597bcee2a8e3c099420` | Arbre du palier pied E5 | | Arbre | Diamètre | empty | yes | 45 mm |
| piece | `cl22c13dbc4d38a1f846323ae6` | Arbre de la cage écureuil pied E3 | | Arbre | Diamètre | empty | yes | 50 mm |
| piece | `cl1406ef19de58fdd1adf40221` | Arbre de la cage écureuil pied E7 | | Arbre | Diamètre | empty | yes | 35 mm |
| piece | `clafaa71cbf49777fbb8415f19` | Arbre du tambour tête E3 | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cle255aea44755dbbe7e466a99` | Arbre du palier tête E5 | | Arbre | Diamètre | empty | yes | 55 mm |
| piece | `cl3d978dd4b071daff8fb185f7` | Arbre du palier pied E1 | | Arbre | Diamètre | empty | yes | 50 mm |
| piece | `cl5e8aba1867089544d71fe2c5` | Arbre du palier tête E4 | | Arbre | Diamètre | empty | yes | 55 mm |
| piece | `cl04c79cd568894a5674b46a31` | Arbre du palier pied élévateur expédition | | Arbre | Diamètre | empty | yes | 50 mm |
| piece | `cl50fe870a07e42759b37b511f` | Arbre du tambour tête E6 | | Arbre | Diamètre | empty | yes | 55 mm |
| piece | `cl531dde45c3fc64c1a3b16ca0` | Arbre de la cage écureuil pied élévateur expédition | | Arbre | Diamètre | empty | yes | 50 mm |
| piece | `cleca9e4baa9e9205f1dd948e1` | Arbre du palier tête E3 | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cl5ee293dc7b61feba510082a4` | Arbre du tambour tête élévateur expédition | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cled68ff759b1f02f482990fb3` | Arbre du tambour du palier tête E11 | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cmkr0qjw5004s1eq6pen63x7j` | Arbre du palier tête E1 | | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cl2c3570dd00372fed44cd5a43` | Arbre du palier tête élévateur expédition | `Décolleter a Ø40 pour réducteur` | Arbre | Diamètre | empty | yes | 70 mm |
| piece | `cl7b3702f04d24d87e47232a14` | Arbre du tambour tête E4 | | Arbre | Diamètre | empty | yes | 55 mm |
| piece | `cldd656c6092225f53a22badc0` | Arbre de la cage écureuil pied E8 | | Arbre | Diamètre | empty | yes | 50 mm |
---
## Observations
### 1. Lot tres homogene
Le resultat est tres concentre :
- uniquement des `pieces`
- uniquement des cas `empty`
- presque uniquement sur le champ `Diamètre`
- presque toute la liste est dans la categorie `Arbre`
Cela ressemble davantage a une vague de perte coherente qu'a du bruit metier aleatoire.
### 2. Valeurs d'audit tres exploitables
Les valeurs retrouvees sont directement reutilisables :
- `35 mm`
- `40 mm`
- `45 mm`
- `50 mm`
- `55 mm`
- `70 mm`
- `20 DTS`
### 3. Cas particulier multi-champs
L'entite `cl8da1b875191c617e5852bf81` a deux champs recuperables :
- `Diamètre`
- `Diamètre palier`
### 4. Piece initialement signalee
La piece `cl731386df55fcb9e6a01e0a63` est bien presente dans le resultat :
- nom : `Arbre de la cage écureuil pied E2 E2B`
- champ : `Diamètre`
- valeur recuperable : `35 mm`
---
## Priorite De Restauration
Priorite haute :
- restaurer tout ce lot `Arbre` en premier
- ce sont des cas homogènes et recuperables
Ordre recommande :
1. piece `cl731386df55fcb9e6a01e0a63`
2. piece avec plusieurs champs recuperables : `cl8da1b875191c617e5852bf81`
3. reste du lot `Arbre`
---
## Commandes Utiles
Dry-run pour une piece :
```bash
php bin/console app:restore-piece-custom-field-values <pieceId>
```
Application reelle :
```bash
php bin/console app:restore-piece-custom-field-values <pieceId> --apply
```
Exemple pour la piece initiale :
```bash
php bin/console app:restore-piece-custom-field-values cl731386df55fcb9e6a01e0a63
php bin/console app:restore-piece-custom-field-values cl731386df55fcb9e6a01e0a63 --apply
```

View File

@@ -0,0 +1,137 @@
# Doublons de références — Composants
> Généré le 2026-03-26 à partir du dump de production `inventory (17).sql.gz`
**13 références en doublon** pour un total de **41 composants concernés**.
## Résumé
| Référence | Nb | Composants |
|---|---|---|
| Tambour lisse | 9 | Tambour tête E1, E2 E2B, E3, E4, E5, E6, E7, E8, élévateur expédition |
| FY50 FM | 5 | Opposé commande Vis 21, Palier Opposé Commande Vis 19, Palier Vis 18 (côté commande), Palier Vis 21 (côté commande), Palier côté commande Vis 20 |
| PB 2220 | 4 | Réducteur pendulaire E1, E3, E8, élévateur expédition |
| SNU 511 609 | 4 | Palier pied E1, E3, E8, élévateur expédition |
| SNU 516 613 | 4 | Palier tête E1, E3, E8, élévateur expédition |
| 512610 SNH SKF | 3 | Palier tête E4, E5, E6 |
| FY 50 FM | 2 | Palier V18 (opposé commande), Palier côté commande Vis 19 |
| FY60 | 2 | Palier Vis 17 (coté commande), Palier Vis 17 (opposé commande) |
| FY60 WF | 2 | Palier Opposé commande Vis 22, Palier côté commande Vis 22 |
| PB 2012 | 2 | Réducteur pendulaire E2-E2B, E7 |
| PB 2112 | 2 | Réducteur pendulaire E4, E6 |
| SNU 509 | 2 | Palier tête E2 et E2B, E7 |
| VCF 207 | 2 | Palier pied E2 et E2B, E7 |
## Détail par référence
### Tambour lisse (9 composants)
| Nom | ID |
|---|---|
| Tambour tête E1 | cl4660bae41d2af254e6c3b726 |
| Tambour tête E2 E2B | cl5e9c6b18bccd38517026dc1c |
| Tambour tête E3 | clba5633e840726188261145f9 |
| Tambour tête E4 | cl10c0924d10135c5f515378ac |
| Tambour tête E5 | cl7f254c23161d9c853c3e6d92 |
| Tambour tête E6 | cl3dbac5194bc192a0589465ba |
| Tambour tête E7 | cla833681664bb851ca61aca51 |
| Tambour tête E8 | cl36d84884cad86fbc92dba133 |
| Tambour tête élévateur expédition | cl5a8f9656aa7e14c012f30700 |
### FY50 FM (5 composants)
| Nom | ID |
|---|---|
| Opposé commande Vis 21 | cl055eff4115f9c75d850c9459 |
| Palier Opposé Commande Vis 19 | cl6831a23892243bbaa2f823b4 |
| Palier Vis 18 (côté commande) | cld1391112241147dc064b35da |
| Palier Vis 21 (côté commande) | cl9f8253f4537a657f7378a2e9 |
| Palier côté commande Vis 20 | cl203937da81135d8b34d7bb0f |
### PB 2220 (4 composants)
| Nom | ID |
|---|---|
| Réducteur pendulaire E1 | cla59f867feafbb0937862064a |
| Réducteur pendulaire E3 | cl33683086c4de13f80db59606 |
| Réducteur pendulaire E8 | cl94fb77cf922aa1462a8d13cc |
| Réducteur pendulaire élévateur expédition | cl3f02941228dfef4c91a75d1a |
### SNU 511 609 (4 composants)
| Nom | ID |
|---|---|
| Palier pied E1 | cl81e703e9f200163a4ea473df |
| Palier pied E3 | cl3d38928c11d70614bb09fe8e |
| Palier pied E8 | cl78b79a8f90f12842b5683403 |
| Palier pied élévateur expédition | clf35b4455617ae94f2a1add46 |
### SNU 516 613 (4 composants)
| Nom | ID |
|---|---|
| Palier tête E1 | cmkr0nq1a004e1eq6v6ubxlfl |
| Palier tête E3 | cl92b8908c71616c542d958007 |
| Palier tête E8 | clce6dde0609d90764da383d75 |
| Palier tête élévateur expédition | clb7322b05f9a4554fa5a75d5a |
### 512610 SNH SKF (3 composants)
| Nom | ID |
|---|---|
| Palier tête E4 | cl8e90ad1b633046f5f1344b93 |
| Palier tête E5 | clbbe4096490ff89b08644c793 |
| Palier tête E6 | cl51c9a1c3dce52856e3404a38 |
### FY 50 FM (2 composants)
| Nom | ID |
|---|---|
| Palier V18 (opposé commande) | cl2ff55d9fa9c52c18f2d88222 |
| Palier côté commande Vis 19 | clbddd1dca5efa881b23eaa1cd |
### FY60 (2 composants)
| Nom | ID |
|---|---|
| Palier Vis 17 (coté commande) | cl02b0a0a543cc699681b6ae8c |
| Palier Vis 17 (opposé commande) | clc0ba9245b63613307cc26a19 |
### FY60 WF (2 composants)
| Nom | ID |
|---|---|
| Palier Opposé commande Vis 22 | cl318b49462097fb2e1f793305 |
| Palier côté commande Vis 22 | cl6bc818a2d8661b5e0ce2d0c0 |
### PB 2012 (2 composants)
| Nom | ID |
|---|---|
| Réducteur pendulaire E2-E2B | cl9b746a66f583fc85b3d176c4 |
| Réducteur pendulaire E7 | clc0db3b431d75c6355608efd5 |
### PB 2112 (2 composants)
| Nom | ID |
|---|---|
| Réducteur pendulaire E4 | clf5a1c9e1f8202b632f173bd3 |
| Réducteur pendulaire E6 | cle1899c6522cb8b8abd366a24 |
### SNU 509 (2 composants)
| Nom | ID |
|---|---|
| Palier tête E2 et E2B | cl4e600dcadb34f817a888ffa3 |
| Palier tête E7 | cl84271e9ab5351cbd188b0d3a |
### VCF 207 (2 composants)
| Nom | ID |
|---|---|
| Palier pied E2 et E2B | cld516a118bb1c478722a1d39b |
| Palier pied E7 | cl908dbf171798f087b12d6f2a |
## Note
Ces doublons sont des composants **distincts** (noms différents, installés sur différents élévateurs) qui partagent la même référence fournisseur. Il ne s'agit pas nécessairement d'entrées à fusionner, mais de pièces identiques utilisées à plusieurs emplacements.

399
docs/FONCTIONNEMENT.md Normal file
View File

@@ -0,0 +1,399 @@
# Fonctionnement de l'application Inventory
## 1. A quoi sert cette application ?
Inventory est une application de **gestion d'inventaire industriel**. Elle permet de suivre et documenter l'ensemble du parc de machines d'une entreprise, avec tous les elements qui les composent : composants, pieces detachees et produits consommables.
L'objectif principal est d'avoir une **vue complete et structuree** de chaque machine : quels composants elle contient, quelles pieces sont montees dessus, quels produits sont utilises, qui les fabrique, combien ils coutent, et toute la documentation associee (manuels, fiches techniques, etc.).
---
## 2. Les entites principales
L'application s'articule autour de 7 entites fondamentales :
```
+-----------------------------------------------------------+
| SITE |
| (usine, atelier, entrepot...) |
| - nom, adresse, contact, telephone, ville, code postal |
| - couleur (pour identification visuelle) |
+-----------------------------------------------------------+
|
| contient des
v
+-----------------------------------------------------------+
| MACHINE |
| (machine industrielle sur un site) |
| - nom (unique), reference, prix |
| - rattachee a 1 site obligatoirement |
| - peut avoir plusieurs fournisseurs/constructeurs |
+-----------------------------------------------------------+
|
| est composee de
v
+-------------------+ +-------------------+ +-------------------+
| COMPOSANT | | PIECE | | PRODUIT |
| (element fonct.) | | (piece detachee) | | (consommable) |
| - nom, ref, desc | | - nom, ref, desc | | - nom, ref |
| - prix | | - prix | | - prix fournisseur |
| - categorie | | - categorie | | - categorie |
| - fournisseurs | | - fournisseurs | | - fournisseurs |
+-------------------+ +-------------------+ +-------------------+
```
### Site
Un **site** represente un lieu physique : une usine, un atelier, un entrepot. Chaque site possede un nom, une adresse complete et un contact. Toutes les machines sont obligatoirement rattachees a un site.
### Machine
Une **machine** est l'entite centrale. C'est un equipement industriel installe sur un site. Chaque machine a un nom unique, une reference optionnelle et un prix. Elle contient une structure hierarchique de composants, pieces et produits.
### Composant
Un **composant** represente un element fonctionnel d'une machine (ex : un moteur, un systeme hydraulique, un automate). Un composant peut lui-meme contenir des sous-composants, des pieces et des produits, formant une structure arborescente.
### Piece
Une **piece** est une piece detachee (ex : un roulement, un joint, un filtre). Les pieces peuvent etre rattachees directement a une machine ou a un composant au sein d'une machine.
### Produit
Un **produit** est un consommable ou article fournisseur (ex : huile, lubrifiant, boulon specifique). Comme les pieces, les produits peuvent etre associes a une machine, a un composant ou a une piece.
### Constructeur (Fournisseur)
Un **constructeur** est un fabricant ou fournisseur. C'est un referentiel partage : le meme fournisseur peut etre associe a des machines, des composants, des pieces et des produits. Chaque fournisseur a un nom, un email et un telephone.
### Categorie (ModelType)
Une **categorie** (appelee ModelType dans le systeme) permet de classifier les composants, les pieces et les produits. Le systeme de categories est explique en detail dans la section suivante.
---
## 3. Le systeme de categories (ModelType)
Les categories sont un element cle de l'application. Elles servent a **classifier ET a structurer** les elements de l'inventaire.
### Trois familles de categories
Il existe trois familles de categories, une par type d'element :
| Famille | S'applique aux | Exemples |
|-------------|----------------|-----------------------------------------|
| COMPONENT | Composants | "Moteur electrique", "Systeme hydraulique" |
| PIECE | Pieces | "Roulement", "Joint torique", "Filtre" |
| PRODUCT | Produits | "Huile moteur", "Graisse", "Boulon M8" |
### Le squelette (skeleton) : la structure imposee
La vraie puissance des categories de composants reside dans leur **squelette**. Quand on cree une categorie de composant, on definit un modele qui impose :
- **Quelles pieces** sont necessaires (par type de piece)
- **Quels produits** sont necessaires (par type de produit)
- **Quels sous-composants** sont necessaires (par type de composant)
- **Quels champs personnalises** doivent etre remplis
**Exemple concret :** La categorie "Moteur electrique" pourrait imposer :
- 1 piece de type "Roulement"
- 1 piece de type "Joint"
- 1 produit de type "Huile moteur"
- 1 sous-composant de type "Variateur"
- Des champs personnalises : "Puissance (kW)", "Vitesse (tr/min)", "Tension (V)"
```
Categorie "Moteur electrique" (squelette)
|
|-- Piece requise : type "Roulement" --> l'utilisateur choisira quel roulement precis
|-- Piece requise : type "Joint" --> l'utilisateur choisira quel joint precis
|-- Produit requis : type "Huile moteur" --> l'utilisateur choisira quelle huile precise
|-- Sous-composant : type "Variateur" --> l'utilisateur choisira quel variateur precis
|-- Champ personnalise : "Puissance (kW)" --> l'utilisateur saisira la valeur
|-- Champ personnalise : "Tension (V)" --> l'utilisateur saisira la valeur
```
Les categories de pieces peuvent elles aussi definir des produits requis et des champs personnalises. Les categories de produits peuvent definir des champs personnalises.
### Champs personnalises
Les champs personnalises permettent d'ajouter des informations specifiques selon la categorie. Chaque champ a :
- Un **nom** (ex : "Puissance")
- Un **type** (texte, nombre, date, etc.)
- Un caractere **obligatoire ou non**
- Des **options** possibles (pour les listes deroulantes)
- Une **valeur par defaut**
- Un **ordre d'affichage**
Les machines disposent aussi de champs personnalises, mais ceux-ci sont definis directement sur chaque machine (et non via une categorie).
---
## 4. Le cycle de vie d'un composant
Voici les etapes typiques de creation et utilisation d'un composant :
```
1. CREATION 2. SELECTION CATEGORIE 3. REMPLISSAGE SQUELETTE
+-------------------+ +------------------------+ +---------------------------+
| Saisir : | | Choisir la categorie : | | Le squelette apparait : |
| - Nom | ----> | "Moteur electrique" | --> | - Piece "Roulement" : [?] |
| - Reference | | | | - Piece "Joint" : [?] |
| - Description | | Le systeme charge le | | - Produit "Huile" : [?] |
| - Prix | | squelette associe | | |
| - Fournisseurs | +------------------------+ | Choisir dans le catalogue |
+-------------------+ | chaque element concret |
+---------------------------+
|
5. DOCUMENTS 4. CHAMPS PERSONNALISES
+---------------------+ +-----------------------------+
| Joindre des fichiers | <---- | Remplir les champs definis |
| - Manuels PDF | | par la categorie : |
| - Fiches techniques | | - Puissance : 15 kW |
| - Photos | | - Tension : 400 V |
| - Schemas | | - Vitesse : 1500 tr/min |
+---------------------+ +-----------------------------+
```
**Etape 1 - Creation :** L'utilisateur saisit les informations de base du composant (nom, reference, description, prix) et selectionne un ou plusieurs fournisseurs.
**Etape 2 - Selection de la categorie :** L'utilisateur choisit la categorie du composant (ex : "Moteur electrique"). Le systeme charge alors le squelette defini pour cette categorie.
**Etape 3 - Remplissage du squelette :** Des "emplacements" (slots) apparaissent pour chaque element requis par le squelette. L'utilisateur selectionne dans le catalogue existant les pieces, produits et sous-composants concrets qui correspondent a chaque emplacement.
**Etape 4 - Champs personnalises :** L'utilisateur remplit les champs personnalises definis par la categorie (puissance, tension, etc.).
**Etape 5 - Documents :** L'utilisateur peut joindre des fichiers au composant : manuels PDF, fiches techniques, photos, schemas...
Ce meme principe s'applique aux pieces (qui peuvent avoir des produits associes et des champs personnalises definis par leur categorie) et aux produits (qui peuvent avoir des champs personnalises).
---
## 5. Les roles utilisateurs
L'application utilise 4 niveaux de droits, organises en hierarchie. Chaque role herite automatiquement des droits du role inferieur :
```
+------------------------------------------------------------------+
| ROLE_ADMIN |
| Tout faire + gerer les utilisateurs (creer, modifier, supprimer |
| des comptes, attribuer des roles) |
+------------------------------------------------------------------+
| herite de
v
+------------------------------------------------------------------+
| ROLE_GESTIONNAIRE |
| Creer, modifier et supprimer les machines, composants, pieces, |
| produits, sites, fournisseurs, categories, documents, |
| commentaires. C'est le role d'edition principal. |
+------------------------------------------------------------------+
| herite de
v
+------------------------------------------------------------------+
| ROLE_VIEWER |
| Consulter tout l'inventaire en lecture seule : naviguer dans |
| les machines, voir les structures, les catalogues, l'historique |
| et les documents. |
+------------------------------------------------------------------+
| herite de
v
+------------------------------------------------------------------+
| ROLE_USER |
| Role de base attribue automatiquement a tout utilisateur |
| connecte. Acces minimal. |
+------------------------------------------------------------------+
```
En resume :
- **Admin** : fait tout, y compris gerer les comptes utilisateurs
- **Gestionnaire** : cree et modifie les donnees de l'inventaire
- **Viewer** : consulte l'inventaire sans pouvoir le modifier
- **User** : role de base, acces minimal
---
## 6. Les fonctionnalites cles
### Catalogues
L'application propose des **catalogues** pour chaque type d'element :
- **Catalogue des composants** : liste tous les composants avec recherche par nom, reference ou categorie
- **Catalogue des pieces** : liste toutes les pieces detachees
- **Catalogue des produits** : liste tous les produits fournisseurs
- **Liste des machines** : toutes les machines, organisees par site
- **Liste des sites** : tous les sites industriels
- **Liste des fournisseurs** : tous les constructeurs/fournisseurs
Chaque catalogue offre des fonctions de **recherche**, de **tri** et de **pagination**.
### Recherche
La recherche est disponible dans tous les catalogues et permet de filtrer par :
- Nom (recherche partielle, insensible a la casse)
- Reference (recherche partielle)
- Categorie (filtre exact ou par nom)
### Historique et audit
Chaque modification dans l'application est **tracee automatiquement**. Le systeme enregistre :
- **Qui** a fait la modification (quel utilisateur)
- **Quand** la modification a ete faite
- **Quoi** a ete modifie (les champs avant/apres)
- **Sur quel element** (machine, composant, piece, produit...)
On peut consulter :
- L'**historique d'une entite** : toutes les modifications apportees a une machine, un composant, etc.
- Le **journal d'activite global** : toutes les modifications recentes dans l'application
### Commentaires
Les utilisateurs peuvent **commenter** n'importe quel element de l'inventaire (machines, composants, pieces, produits, categories). Les commentaires ont un systeme de **resolution** : un commentaire ouvert peut etre marque comme "resolu" par un gestionnaire. Un compteur de commentaires non resolus est disponible.
### Documents
Des fichiers peuvent etre joints a toutes les entites principales :
- **Sites** : plans, reglements
- **Machines** : manuels, fiches techniques
- **Composants** : documentations constructeur
- **Pieces** : plans de pieces, specifications
- **Produits** : fiches de donnees de securite, catalogues
Les fichiers sont uploades via l'interface et peuvent etre consultes ou telecharges a tout moment. L'application gere differents formats : PDF, images, etc.
### Clonage de machines
Quand une nouvelle machine est identique ou similaire a une existante, il est possible de **cloner une machine**. Le clonage copie :
- Les champs personnalises et leurs valeurs
- Toute la structure : les liens vers les composants, pieces et produits
- La hierarchie (quel composant contient quelles pieces, etc.)
L'utilisateur choisit un nouveau nom et un site de destination. La machine clonee peut ensuite etre modifiee independamment de l'originale.
---
## 7. La structure des machines
### Vue d'ensemble
Chaque machine possede une **structure hierarchique** qui decrit de quoi elle est composee. Cette structure est une arborescence :
```
Machine "Presse hydraulique PH-200"
|
|-- Composant "Moteur principal M1"
| |-- Piece "Roulement SKF 6205" (quantite: 2)
| | |-- Produit "Graisse SKF LGMT2"
| |-- Piece "Joint Viton DN50"
| |-- Produit "Huile Total Azolla ZS 46"
| |-- Sous-composant "Variateur ABB ACS580"
| |-- Piece "Fusible 63A"
| |-- Produit "Pate thermique"
|
|-- Composant "Groupe hydraulique GH-01"
| |-- Piece "Filtre Parker 926169Q"
| |-- Piece "Verin Bosch CDT3" (quantite: 4)
| |-- Produit "Huile hydraulique HLP 46"
|
|-- Piece "Courroie Gates PowerGrip" (piece directement sur la machine)
|-- Produit "Boulon M12x50 Inox" (produit directement sur la machine)
```
### Les liens (links)
Les elements ne sont pas directement "dans" la machine. Ils y sont rattaches par des **liens** :
- **MachineComponentLink** : rattache un composant a une machine
- **MachinePieceLink** : rattache une piece a une machine
- **MachineProductLink** : rattache un produit a une machine
Ces liens permettent :
- De definir la **hierarchie** : un composant peut etre parent d'une piece ou d'un produit, un sous-composant peut etre enfant d'un autre composant
- De specifier une **quantite** (ex : 4 verins identiques)
- De faire des **surcharges** : modifier le nom, la reference ou le prix d'un element specifiquement dans le contexte de cette machine, sans modifier l'element du catalogue
### Hierarchie parent-enfant
```
MachineComponentLink (composant dans la machine)
|
|-- parentLink --> null (composant racine, directement dans la machine)
| ou
|-- parentLink --> autre MachineComponentLink (sous-composant)
|
|-- pieceLinks --> MachinePieceLink[] (pieces de ce composant)
|-- productLinks --> MachineProductLink[] (produits de ce composant)
MachinePieceLink (piece dans la machine)
|
|-- parentLink --> MachineComponentLink (piece rattachee a un composant)
| ou
|-- parentLink --> null (piece directement sur la machine)
|
|-- productLinks --> MachineProductLink[] (produits de cette piece)
```
### Catalogue vs. Structure machine
Un point important : les **composants, pieces et produits existent dans un catalogue global**. Quand on les ajoute a une machine, on cree un lien vers l'element du catalogue. Le meme composant du catalogue peut donc etre utilise dans plusieurs machines.
Les surcharges (nom, reference, prix) permettent d'adapter les informations au contexte d'une machine specifique sans modifier la fiche catalogue.
```
CATALOGUE (reference globale) MACHINE (utilisation specifique)
+-------------------------+ +--------------------------------+
| Composant "Moteur 15kW" | | Lien vers "Moteur 15kW" |
| Ref: MOT-15-01 | <-------- | Surcharge nom: "Moteur gauche" |
| Prix: 2500 EUR | | Surcharge prix: 2200 EUR |
+-------------------------+ +--------------------------------+
```
---
## Schemas recapitulatifs
### Relations entre entites
```
+--------+
| Site |
+--------+
|
contient (1..N)
|
+-----------+
| Machine |------------ Fournisseurs (N..N)
+-----------+
/ | \
/ | \
Composants Pieces Produits
(via liens) (via liens) (via liens)
+-----------+ +--------+ +---------+
| Composant | | Piece | | Produit |
+-----------+ +--------+ +---------+
| | |
|-- Categorie |-- Categorie |-- Categorie
|-- Fournisseurs -- Fournisseurs -- Fournisseurs
|-- Documents |-- Documents |-- Documents
|-- Champs perso -- Champs perso -- Champs perso
|
|-- Sous-composants (arborescence)
|-- Pieces (slots depuis le squelette)
|-- Produits (slots depuis le squelette)
```
### Flux de creation typique
```
1. Creer les SITES
|
2. Creer les CATEGORIES (avec leurs squelettes)
|
3. Creer les FOURNISSEURS
|
4. Creer les PRODUITS (en les categorisant)
|
5. Creer les PIECES (en les categorisant, en leur associant des produits)
|
6. Creer les COMPOSANTS (en choisissant une categorie,
| en remplissant le squelette avec des pieces/produits/sous-composants)
|
7. Creer les MACHINES (sur un site)
|
8. STRUCTURER les machines (ajouter composants, pieces, produits)
|
9. DOCUMENTER (joindre des fichiers a chaque element)
```

146
docs/GLOSSAIRE_METIER.md Normal file
View File

@@ -0,0 +1,146 @@
# Glossaire Métier — Inventory
## Contexte
**Inventory** est une application de gestion de parc machines industriel. Elle permet aux équipes de maintenance de cataloguer leurs machines, leurs sous-ensembles (composants), les pièces de rechange et les consommables associés. Chaque machine est rattachée à un site physique (usine, atelier). L'application gère la hiérarchie complète : Machine → Composants → Pièces/Produits, avec traçabilité (audit), documentation technique et champs personnalisables.
---
## Concepts Métier
### Hiérarchie principale
| Terme | Définition | Exemples concrets |
|-------|-----------|-------------------|
| **Site** | Lieu physique (usine, atelier, entrepôt). Regroupe les machines d'un même emplacement. | Usine de Lyon, Atelier Nord |
| **Machine** | Équipement industriel installé sur un site. C'est l'unité de base du parc. Contient des composants, pièces et produits. | Presse hydraulique, Tour CNC, Ligne d'embouteillage |
| **Composant** | Sous-ensemble fonctionnel d'une machine. Peut contenir des pièces, des produits, et d'autres sous-composants (imbrication). | Moteur, Pompe, Tableau électrique, Vérin |
| **Pièce** | Pièce mécanique/physique qu'on monte ou remplace. C'est l'unité de maintenance. | Joint, Écrou, Roulement, Capteur, Courroie |
| **Produit** | Consommable qu'on utilise sans monter. S'use et se renouvelle. | Huile, Dégraissant, Graisse, Liquide de refroidissement |
### Configuration et templates
| Terme | Définition |
|-------|-----------|
| **Modèle Type** (ModelType) | Template réutilisable qui définit la composition attendue d'un composant, d'une pièce ou d'un produit. Par exemple : "Pompe centrifuge XYZ nécessite 2 joints, 1 roulement et de l'huile hydraulique". |
| **Skeleton** (squelette) | La structure "vide" définie par un modèle type : la liste des emplacements requis (pièces, produits, sous-composants) avant qu'on y mette les éléments réels. |
| **Slot** (emplacement) | Emplacement concret dans un composant ou une pièce, créé à partir du skeleton. Chaque slot est à remplir avec une pièce, un produit ou un sous-composant réel. Un slot peut rester vide (pas encore sourcé). |
| **Sync** (synchronisation) | Propagation des modifications d'un modèle type vers tous les composants existants de ce type. Par exemple : ajouter un slot "filtre" au modèle type met à jour tous les composants de ce type. Surtout utilisé en phase de saisie initiale, quand on ajuste les modèles au fur et à mesure qu'on découvre la vraie composition des machines. |
| **Catégorie de modèle** | Un modèle type est classé en 3 catégories : Composant, Pièce ou Produit. Détermine quels skeletons il peut définir. |
### Transverse
| Terme | Définition |
|-------|-----------|
| **Constructeur** | Fournisseur ou fabricant. Peut être associé à une machine, un composant, une pièce ou un produit. Permet de tracer la chaîne d'approvisionnement. |
| **Champ personnalisé** (CustomField) | Attribut dynamique défini par l'utilisateur et attaché à une machine ou à un modèle type. Les composants/pièces/produits d'un même modèle type partagent les mêmes champs personnalisés. Exemples : "N° de série", "Date de garantie", "Intervalle de maintenance". |
| **Document** | Fichier attaché à n'importe quelle entité (machine, composant, pièce, produit, site, commentaire). Typé : Documentation, Devis, Facture, Plan, Photo, Autre. |
| **Commentaire** | Annotation utilisateur sur une entité, avec un statut ouvert ou résolu. Permet de signaler un problème, poser une question ou laisser une note. Peut contenir des pièces jointes. |
| **Journal d'audit** (AuditLog) | Historique automatique et immuable de toutes les créations, modifications et suppressions. Enregistre qui a fait quoi, quand, avec le détail des changements. |
### Utilisateurs et rôles
| Rôle | Droits |
|------|--------|
| **Admin** | Accès complet : gestion des utilisateurs, configuration, toutes les opérations |
| **Gestionnaire** | Créer, modifier, supprimer des machines/composants/pièces/produits |
| **Viewer** | Consultation seule, pas de modification |
| **User** | Rôle de base (accès minimal) |
---
## Workflows Utilisateur
### 1. Créer une machine
1. Choisir le **site** où la machine est installée
2. Renseigner nom, référence, prix, fournisseur(s)
3. Ajouter des **composants** à la machine (voir workflow 2)
4. Ajouter des **pièces** et **produits** directement sur la machine si nécessaire
5. Ajouter des **champs personnalisés** et des **documents**
### 2. Ajouter un composant à une machine
1. Choisir un **modèle type** pour le composant (ex: "Pompe centrifuge XYZ")
2. Les **slots** sont pré-créés automatiquement à partir du skeleton du modèle type
3. Remplir chaque slot en sélectionnant la pièce/produit/sous-composant réel
4. Les slots peuvent rester vides et être remplis plus tard
### 3. Créer ou modifier un modèle type
1. Nommer le modèle type et choisir sa catégorie (Composant, Pièce ou Produit)
2. Définir les emplacements requis : quelles pièces, quels produits, quels sous-composants
3. Définir les champs personnalisés (métadonnées) pour les entités de ce type
4. Si des composants existent déjà avec ce modèle type → utiliser le **sync** (workflow 4)
### 4. Synchroniser un modèle type
1. Modifier les emplacements du modèle type (ajout/suppression de slots)
2. Lancer un **sync preview** : visualiser l'impact sur les composants existants
3. Confirmer → les slots sont ajoutés/supprimés sur tous les composants du type
4. Surtout utile en phase de saisie initiale quand les données sont ajustées progressivement
### 5. Cloner une machine
1. Sélectionner une machine existante
2. Lancer le clonage → copie complète (composants, pièces, produits, liens, champs personnalisés)
3. Renommer la machine clonée et l'affecter à un site
### 6. Gérer les documents
1. Sélectionner une entité (machine, composant, pièce, produit, site)
2. Uploader un fichier (PDF, image, etc.)
3. Choisir le type : Documentation, Devis, Facture, Plan, Photo, Autre
4. Les documents sont consultables et téléchargeables depuis la fiche de l'entité
---
## Relations — Vue d'ensemble
```
Site
└── Machine
├── Composant (→ défini par un Modèle Type)
│ ├── Slot Pièce → Pièce (joint, écrou…)
│ ├── Slot Produit → Produit (huile, dégraissant…)
│ └── Slot Sous-composant → Composant (imbrication)
├── Pièce (directement sur la machine)
│ └── Slot Produit → Produit
└── Produit (directement sur la machine)
Modèle Type (template)
├── Skeleton Pièce Requirement → "il faut une pièce de type X"
├── Skeleton Produit Requirement → "il faut un produit de type Y"
└── Skeleton Sous-composant Requirement → "il faut un composant de type Z"
Transverse (attachable à toute entité) :
• Constructeur (fournisseur)
• Document (fichier)
• Commentaire (annotation)
• Champ personnalisé (métadonnée dynamique)
• Journal d'audit (historique automatique)
```
---
## Correspondance Métier ↔ Code
| Terme métier | Entité code | Table PG |
|-------------|-------------|----------|
| Site | `Site` | `site` |
| Machine | `Machine` | `machine` |
| Composant | `Composant` | `composant` |
| Pièce | `Piece` | `piece` |
| Produit | `Product` | `product` |
| Modèle Type | `ModelType` | `model_type` |
| Slot pièce (composant) | `ComposantPieceSlot` | `composant_piece_slot` |
| Slot produit (composant) | `ComposantProductSlot` | `composant_product_slot` |
| Slot sous-composant | `ComposantSubcomponentSlot` | `composant_subcomponent_slot` |
| Slot produit (pièce) | `PieceProductSlot` | `piece_product_slot` |
| Skeleton pièce | `SkeletonPieceRequirement` | `skeleton_piece_requirement` |
| Skeleton produit | `SkeletonProductRequirement` | `skeleton_product_requirement` |
| Skeleton sous-composant | `SkeletonSubcomponentRequirement` | `skeleton_subcomponent_requirement` |
| Constructeur | `Constructeur` | `constructeur` |
| Champ personnalisé | `CustomField` | `custom_field` |
| Valeur champ perso | `CustomFieldValue` | `custom_field_value` |
| Document | `Document` | `document` |
| Commentaire | `Comment` | `comment` |
| Journal d'audit | `AuditLog` | `audit_log` |
| Utilisateur | `Profile` | `profile` |
| Lien machine-composant | `MachineComponentLink` | `machine_component_link` |
| Lien machine-pièce | `MachinePieceLink` | `machine_piece_link` |
| Lien machine-produit | `MachineProductLink` | `machine_product_link` |

346
docs/REVIEW_ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,346 @@
# Revue d'architecture - Sources de complexite et effets de bord
Date : 2026-03-23
Branche analysee : `develop`
---
## Diagnostic - Top 10 des sources de complexite
| # | Source | Impact | Effort |
|---|--------|--------|--------|
| 1 | Duplication massive du `smartMatch` dans les Sync Strategies | Bugs silencieux, maintenance triple | M |
| 2 | Custom Fields : 4 FK nullable sur une seule entite (polymorphisme pauvre) | Integrite fragile, code defensif partout | L |
| 3 | Composables frontend geants avec responsabilites multiples | Difficile a tester, refactoring risque | M |
| 4 | 3 fichiers utils de custom fields frontend avec logique qui se chevauche | Incoherences, bugs de merge/dedup | M |
| 5 | `pendingStructure` : canal de communication cache entre deserialisation et processor | Effet de bord invisible, timing fragile | S |
| 6 | `PieceProductSyncSubscriber` : legacy sync dans un subscriber Doctrine | Side effect cache, recompute du changeset | S |
| 7 | Double flush dans les processors (decorated + flush manuel) | Audit logs potentiellement incomplets | S |
| 8 | `MachineStructureController` : God controller avec normalisation JSON manuelle | Bypass API Platform, 300+ LOC de serialisation | L |
| 9 | Chaine de dependances circulaire dans `useMachineDetailData` | Proxy refs, ordre d'initialisation fragile | M |
| 10 | Frontend : typage `any` systematique sur les entites | Pas de filet de securite TypeScript | L |
---
## Analyse detaillee
### 1. Duplication du `smartMatch` dans les Sync Strategies
**Fichiers concernes :**
- `/src/Service/Sync/ComposantSyncStrategy.php` (lignes 380-446)
- `/src/Service/Sync/PieceSyncStrategy.php` (lignes 244-308)
**Probleme :** `smartMatch()`, `smartMatchPreview()` et toute la logique de sync des custom field values sont copiees-collees entre `ComposantSyncStrategy` et `PieceSyncStrategy`. Le `ProductSyncStrategy` a une version simplifiee (pas de slots). Si un bug est corrige dans l'un, il faut penser a le corriger dans l'autre.
**Effets de bord concrets :**
- Un correctif sur le matching des slots dans une strategie peut etre oublie dans l'autre
- Le compteur de preview custom fields utilise `orderIndex` comme cle de matching, ce qui est fragile (reindexation = faux positif)
**Solution proposee (effort M) :**
Extraire un trait ou une classe abstraite `AbstractSlotSyncStrategy` :
```php
// AVANT : smartMatch() duplique dans ComposantSyncStrategy et PieceSyncStrategy
// APRES : extraire dans un trait
trait SlotSyncTrait
{
protected function smartMatch(array $existingTypeIds, array $proposedTypeIds): array
{
// ... logique unique
}
protected function syncCustomFieldValues(
object $entity,
string $fkField,
array $customFields,
bool $confirmDeletions,
): array {
// ... logique unique pour add/remove CFValues
}
}
```
La methode `execute()` de chaque strategie ne garderait que la boucle specifique a son type de slot (piece slots, product slots, subcomponent slots), et deleguerait le matching et la gestion des CF values au trait.
---
### 2. Custom Fields : polymorphisme par FK nullable
**Fichiers concernes :**
- `/src/Entity/CustomField.php` - 4 FK nullable : `machine`, `typeComposant`, `typePiece`, `typeProduct`
- `/src/Entity/CustomFieldValue.php` - 4 FK nullable : `machine`, `composant`, `piece`, `product`
- `/src/Controller/CustomFieldValueController.php` - `resolveTarget()` fait un switch sur 4 types
**Probleme :** Un `CustomFieldValue` peut pointer vers machine OU composant OU piece OU produit via 4 colonnes nullable. Rien n'empeche au niveau DB qu'un CFV pointe vers deux entites en meme temps. Le frontend doit deviner le type cible. Chaque nouveau type d'entite necessite d'ajouter une colonne, un setter, et un cas dans tous les switches.
**Effets de bord concrets :**
- Le `CustomFieldValueController::resolveTarget()` tente 4 cles dans un ordre specifique -- si le payload a `machineId` ET `composantId`, seul `machine` est utilise (silent bug)
- Les audit subscribers (`getOwnerFromCustomFieldValue`) doivent tester chaque getter -- si `getComposant()` renvoie un objet alors que `getMachine()` aussi, le comportement est indetermine
- La serialisation API Platform expose les 4 FK meme quand 3 sont null
**Solution proposee (effort L) :**
Option pragmatique (pas de refactoring DB) : ajouter une colonne discriminante `entityType` (enum) + contrainte CHECK :
```sql
ALTER TABLE custom_field_values
ADD COLUMN entity_type VARCHAR(20) NOT NULL DEFAULT 'machine';
ALTER TABLE custom_field_values
ADD CONSTRAINT chk_single_fk CHECK (
(entity_type = 'machine' AND machineId IS NOT NULL AND composantId IS NULL AND pieceId IS NULL AND productId IS NULL) OR
(entity_type = 'composant' AND composantId IS NOT NULL AND machineId IS NULL AND pieceId IS NULL AND productId IS NULL) OR
(entity_type = 'piece' AND pieceId IS NOT NULL AND machineId IS NULL AND composantId IS NULL AND productId IS NULL) OR
(entity_type = 'product' AND productId IS NOT NULL AND machineId IS NULL AND composantId IS NULL AND pieceId IS NULL)
);
```
Cela securise l'integrite sans changer l'architecture. Le `resolveTarget` et les audit subscribers pourraient ensuite brancher sur `entityType` au lieu de tester 4 FK.
---
### 3. Composables frontend geants (400-550 LOC)
**Fichiers concernes :**
- `/Inventory_frontend/app/composables/useComponentEdit.ts` (550 LOC)
- `/Inventory_frontend/app/composables/usePieceEdit.ts` (472 LOC)
- `/Inventory_frontend/app/composables/useMachineDetailData.ts` (468 LOC)
- `/Inventory_frontend/app/composables/useComponentCreate.ts` (417 LOC)
**Probleme :** Ces composables orchestrent en un seul fichier : le chargement de donnees, la gestion de formulaire, la persistence des custom fields, la gestion des documents, l'historique, la resolution de labels, et la soumission. Chacun instancie 8-12 sous-composables.
**Effets de bord concrets :**
- `useComponentEdit` instancie `usePieces()`, `useProducts()`, `useComposants()` a chaque montage de page, meme si ces catalogues sont deja charges -- requetes API en double
- La logique de soumission (`submitEdition`, `submitCreation`) melange la construction du payload, la validation, l'appel API, et la persistence des custom fields -- si une etape echoue, l'etat local est partiellement modifie
- Les watchers sur `selectedType`/`selectedTypeStructure` dans `useComponentCreate` et `useComponentEdit` font des choses differentes pour le meme concept -- source de divergence
**Solution proposee (effort M) :**
Decouper chaque composable geant en sous-composables par responsabilite, comme deja fait pour `useMachineDetailData` (qui delegue a `useMachineDetailDocuments`, `useMachineDetailCustomFields`, etc.) :
```
useComponentEdit.ts (550 LOC)
-> useComponentEditForm.ts (~100 LOC : reactive form, validation)
-> useComponentEditDocuments.ts (~80 LOC : upload, preview, delete)
-> useComponentEditSlots.ts (~120 LOC : slot selection/save)
-> useComponentEditCustomFields.ts (~60 LOC : build inputs, save)
-> useComponentEdit.ts (~150 LOC : orchestrateur)
```
Appliquer le meme pattern a `usePieceEdit` et `useComponentCreate`. Les blocs communs (document handling, custom field save, price formatting) deviendraient des composables partages.
---
### 4. Triple duplication de la logique custom fields frontend
**Fichiers concernes :**
- `/Inventory_frontend/app/shared/utils/customFieldFormUtils.ts` (404 LOC) - pour les pages create/edit
- `/Inventory_frontend/app/shared/utils/customFieldUtils.ts` (440 LOC) - pour la page machine detail
- `/Inventory_frontend/app/shared/utils/entityCustomFieldLogic.ts` (335 LOC) - pour les composants item
**Probleme :** Ces 3 fichiers resolvent le meme probleme (normaliser des definitions de custom fields + merger avec des valeurs existantes) avec des implementations differentes :
- `customFieldFormUtils.ts` : `resolveFieldName()`, `resolveFieldType()`, `buildCustomFieldInputs()`
- `entityCustomFieldLogic.ts` : `resolveFieldName()` (differente!), `resolveFieldType()` (differente!), `mergeFieldDefinitionsWithValues()`
- `customFieldUtils.ts` : `extractDefinitionName()`, `normalizeExistingCustomFieldDefinitions()`, `mergeCustomFieldValuesWithDefinitions()`
**Effets de bord concrets :**
- Trois facons differentes de resoudre le nom d'un champ -- `resolveFieldName` dans `customFieldFormUtils` teste `name`, `key`, `label` ; dans `entityCustomFieldLogic` elle teste `name` seulement et retourne `'Champ'` par defaut
- Trois algorithmes de merge values/definitions -- un bug corrige dans l'un n'est pas corrige dans les autres
- La deduplication par `name+type` dans `entityCustomFieldLogic.ts` et par `orderIndex` dans `customFieldUtils.ts` produit des resultats differents pour les memes donnees
**Solution proposee (effort M) :**
Fusionner en un seul module `customFields.ts` avec :
1. Une seule fonction `resolveFieldName(field: any): string`
2. Une seule fonction `mergeDefinitionsWithValues(defs, values): MergedField[]`
3. Une seule fonction `deduplicateFields(fields): MergedField[]`
Les 3 fichiers actuels deviendraient des re-exports ou des wrappers fins. Commencer par aligner les signatures, puis remplacer les imports un par un.
---
### 5. `pendingStructure` : canal de communication cache
**Fichiers concernes :**
- `/src/Entity/ModelType.php` et `/src/Entity/Composant.php` -- propriete `#[ApiProperty]` non mappee en DB
- `/src/State/ModelTypeProcessor.php` (lignes 33-43)
- `/src/State/ComposantProcessor.php` (lignes 42-51)
**Probleme :** Le champ `structure` envoye par le frontend est intercepte par API Platform dans un champ `pendingStructure` (non mappe en DB), puis lu par le processor apres le `persist` du decorated processor. Ce mecanisme est invisible : rien dans l'entite n'indique qu'un setter a un effet de bord differe.
**Effets de bord concrets :**
- Si le `decorated->process()` leve une exception, le `pendingStructure` reste dans l'entite -- pas de cleanup
- Le `flush()` supplementaire dans le processor (ligne 43 de `ModelTypeProcessor`) declenche les audit subscribers une deuxieme fois pour le meme cycle de request -- les snapshots d'audit peuvent capturer un etat intermediaire
- Un developpeur qui modifie le `ModelType` via Doctrine directement (fixture, migration, CLI) ne beneficie pas de ce mecanisme -- les skeleton requirements ne sont pas mis a jour
**Solution proposee (effort S) :**
Documenter explicitement ce pattern dans l'entite avec un docblock. Ajouter un `try/finally` pour le cleanup :
```php
// ModelTypeProcessor::process()
try {
$result = $this->decorated->process($data, $operation, $uriVariables, $context);
if (null !== $pendingStructure) {
$this->skeletonStructureService->updateSkeletonRequirements($data, $pendingStructure);
$this->entityManager->flush();
}
return $result;
} finally {
$data->clearPendingStructure();
}
```
---
### 6. `PieceProductSyncSubscriber` : side effect cache
**Fichier concerne :**
- `/src/EventSubscriber/PieceProductSyncSubscriber.php`
**Probleme :** Ce subscriber Doctrine ecoute `prePersist` et `preUpdate` pour synchroniser la relation legacy `product` (ManyToOne) avec la collection `productIds` (JSON array). Sur `preUpdate`, il fait un `recomputeSingleEntityChangeSet` (ligne 50-51), ce qui modifie le changeset en cours de flush.
**Effets de bord concrets :**
- Le recompute du changeset peut interferer avec les audit subscribers qui lisent ce meme changeset -- l'audit log peut capturer le changement de `product` comme une modification manuelle alors qu'il est automatique
- L'ordre d'execution des subscribers n'est pas garanti -- si l'audit subscriber s'execute avant le sync, il ne voit pas le changement de `product`
- Si `productIds` est vide, le subscriber ne touche pas `product` -- mais si `product` avait deja une valeur, elle reste (pas de cleanup)
**Solution proposee (effort S) :**
Remplacer ce subscriber par une logique explicite dans le controller/processor qui traite les pieces. Le sync `productIds -> product` devrait etre fait AVANT le flush, pas dans un subscriber. Cela supprime l'ambiguite sur l'ordre d'execution et le recompute.
Alternativement, si la relation legacy `product` (ManyToOne) n'est plus utilisee par le frontend, la supprimer completement et ne garder que `productIds` / les product slots.
---
### 7. Double flush dans les processors
**Fichiers concernes :**
- `/src/State/ModelTypeProcessor.php` (ligne 36 via decorated, ligne 43 manuellement)
- `/src/State/ComposantProcessor.php` (ligne 45 via decorated, ligne 132 manuellement)
**Probleme :** Le decorated processor fait un `flush()` pour persister l'entite, puis un second `flush()` est appele pour persister les skeleton requirements ou slots. Chaque flush declenche `onFlush` dans tous les audit subscribers.
**Effets de bord concrets :**
- Le premier flush capture le `create` de l'entite dans l'audit log
- Le second flush peut generer un `update` de la meme entite si les slots ont modifie une relation qui declenche un dirty check (par ex. si `$composant->incrementVersion()` etait appele)
- En cas d'erreur entre les deux flush, l'entite est persistee mais ses slots ne le sont pas -- etat inconsistant
**Solution proposee (effort S) :**
Wrapper les deux operations dans une transaction explicite, et ne faire qu'un seul flush a la fin :
```php
public function process(mixed $data, Operation $operation, ...): mixed
{
return $this->entityManager->wrapInTransaction(function () use ($data, $operation, ...) {
// Ne pas flush dans le decorated -- utiliser le mode COMMIT_ON_CLOSE
$result = $this->decorated->process($data, $operation, $uriVariables, $context);
if (null !== $pendingStructure) {
$this->skeletonStructureService->updateSkeletonRequirements($data, $pendingStructure);
}
$data->clearPendingStructure();
// Un seul flush
$this->entityManager->flush();
return $result;
});
}
```
> Note : cela necessite de verifier que le decorated processor ne fait pas deja un flush interne non configurable. Si c'est le cas, il faudrait potentiellement ne pas utiliser le decorated et gerer le persist manuellement.
---
### 8. `MachineStructureController` : God controller
**Fichier concerne :**
- `/src/Controller/MachineStructureController.php` (300+ LOC)
**Probleme :** Ce controller gere GET structure, PATCH structure, et POST clone. Il contient toute la logique de normalisation JSON des links (component, piece, product), la resolution des entites, et la serialisation manuelle de la reponse -- tout ce qu'API Platform fait normalement automatiquement.
**Effets de bord concrets :**
- La normalisation JSON manuelle (`normalizeStructureResponse`) ne passe pas par les serialization groups d'API Platform -- si un champ est ajoute a une entite avec un group, il n'apparaitra pas dans la reponse structure
- Le PATCH structure fait `$this->entityManager->flush()` sans transaction -- si la creation d'un link echoue, les precedents sont deja persistes
- Le clone copie les custom fields mais pas les documents -- comportement potentiellement inattendu
- 8 repositories injectes dans le constructeur -- code smell
**Solution proposee (effort L) :**
1. Extraire la logique de normalisation dans un `MachineStructureSerializer` service
2. Extraire la logique de clone dans un `MachineCloneService`
3. Wrapper le PATCH et le clone dans des transactions
4. A terme, considerer un DTO + custom provider/processor API Platform pour le GET/PATCH structure
---
### 9. Dependance circulaire dans `useMachineDetailData`
**Fichier concerne :**
- `/Inventory_frontend/app/composables/useMachineDetailData.ts` (lignes 119-187)
**Probleme :** `useMachineDetailProducts` a besoin de `machineProductLinks` (venant de hierarchy), et `useMachineDetailHierarchy` a besoin de `findProductById` (venant de products). La solution actuelle utilise un `_machineProductLinksProxy` ref avec un watcher pour synchroniser.
**Effets de bord concrets :**
- Le proxy ref est mis a jour de facon asynchrone via un watcher -- pendant le premier tick de rendu, `_machineProductLinksProxy` est vide meme si les liens sont deja charges
- L'ordre d'initialisation des sous-composables est fragile -- deplacer une ligne peut casser la boucle
- Le commentaire dans le code (lignes 119-122) admet explicitement le probleme
**Solution proposee (effort M) :**
Inverser la dependance : le composable `useMachineDetailHierarchy` devrait etre le seul a gerer les links et exposer les product links. `useMachineDetailProducts` ne devrait recevoir que les product IDs (pas les links complets). Cela supprime la circularite.
Alternativement, creer un `useMachineDetailState` purement reactif (store local) qui contient tous les refs partages, et le passer aux sous-composables. Cela explicite les dependances.
---
### 10. Typage `any` systematique sur les entites frontend
**Fichiers concernes :** Quasi tous les composables utilisent `ref<any | null>(null)` pour les entites :
- `useComponentEdit.ts` : `const component = ref<any | null>(null)` (ligne 74)
- `usePieceEdit.ts` : `const piece = ref<any | null>(null)` (ligne 56)
- `useMachineDetailData.ts` : `type AnyRecord = Record<string, unknown>`
**Probleme :** Les reponses API ne sont jamais typees. L'acces aux proprietes se fait par convention (`result.data?.structure?.pieces`) sans aucune validation. TypeScript ne peut pas detecter les typos ou les acces a des proprietes inexistantes.
**Effets de bord concrets :**
- Un changement de nom de champ cote API ne provoque aucune erreur TypeScript -- le bug n'est decouvert qu'au runtime
- L'autocompletion IDE est inutile sur ces objets
- Les defensives checks (`Array.isArray(x?.y) ? x.y : []`) sont necessaires partout parce que le type ne garantit rien
**Solution proposee (effort L) :**
1. Creer des interfaces TypeScript pour les reponses API principales : `MachineStructureResponse`, `ComposantResponse`, `PieceResponse`, `ProductResponse`, `ModelTypeResponse`
2. Ajouter une couche de validation a la reception dans `useApi.ts` (optionnelle, avec Zod ou un type guard maison)
3. Remplacer progressivement `ref<any>` par `ref<ComposantResponse | null>`
Commencer par les entites les plus utilisees (Machine, Composant) pour obtenir un benefice immediat.
---
## Plan de simplification -- Ordre recommande
### Phase 1 : Quick wins (1-2 jours chacun, impact immediat)
| # | Action | Source | Effort |
|---|--------|--------|--------|
| 1 | Extraire `smartMatch` + sync CF values dans un trait partage | Source 1 | S |
| 2 | Ajouter `try/finally` sur `clearPendingStructure` | Source 5 | S |
| 3 | Remplacer `PieceProductSyncSubscriber` par logique explicite | Source 6 | S |
| 4 | Wrapper les processors dans des transactions | Source 7 | S |
### Phase 2 : Unification frontend (1-2 semaines)
| # | Action | Source | Effort |
|---|--------|--------|--------|
| 5 | Fusionner les 3 fichiers custom fields utils en un seul | Source 4 | M |
| 6 | Decouper `useComponentEdit` / `usePieceEdit` en sous-composables | Source 3 | M |
| 7 | Resoudre la circularite dans `useMachineDetailData` | Source 9 | M |
### Phase 3 : Renforcement structurel (2-4 semaines)
| # | Action | Source | Effort |
|---|--------|--------|--------|
| 8 | Ajouter la contrainte CHECK sur `custom_field_values` | Source 2 | M |
| 9 | Typer les reponses API principales | Source 10 | L |
| 10 | Extraire services depuis `MachineStructureController` | Source 8 | L |
### Principe directeur
**Commencer par la phase 1** -- elle ne modifie pas les interfaces (ni API ni frontend) et supprime les effets de bord les plus dangereux. La phase 2 est une consolidation frontend qui peut etre faite page par page. La phase 3 est un investissement a plus long terme.
Ne pas tenter de tout refactorer en une fois. Chaque item peut etre un PR isole, testable independamment.

185
docs/mcp/README.md Normal file
View File

@@ -0,0 +1,185 @@
# MCP Server — Inventory
Serveur MCP (Model Context Protocol) pour l'application Inventory. Permet aux assistants IA (Claude, ChatGPT, Codex) de consulter et gérer l'inventaire industriel.
## Prérequis
- Un profil actif avec rôle suffisant (ROLE_VIEWER pour lecture, ROLE_GESTIONNAIRE pour écriture)
- Accès au tunnel pour les clients distants (Claude Desktop, ChatGPT Desktop)
- Docker Compose démarré (`make start`)
## Configuration par client
### Claude Code (local, stdio)
Le fichier `.mcp.json` à la racine du projet est déjà configuré. Remplacez les placeholders :
```json
{
"mcpServers": {
"inventory": {
"command": "docker",
"args": [
"exec", "-i",
"-e", "MCP_PROFILE_ID=VOTRE_PROFILE_ID",
"-e", "MCP_PROFILE_PASSWORD=VOTRE_PASSWORD",
"php-inventory-apache",
"php", "bin/console", "mcp:server"
]
}
}
}
```
### Claude Desktop (HTTP via tunnel)
Dans `claude_desktop_config.json` :
```json
{
"mcpServers": {
"inventory": {
"url": "https://inventory.company-tunnel.com/_mcp",
"headers": {
"X-Profile-Id": "VOTRE_PROFILE_ID",
"X-Profile-Password": "VOTRE_PASSWORD"
}
}
}
}
```
### ChatGPT Desktop / Codex
Meme principe HTTP avec l'URL du tunnel + headers d'auth.
## Catalogue des Tools
### Tools de haut niveau
| Tool | Description | Role |
|------|-------------|------|
| `search_inventory` | Recherche globale (machines, pieces, composants, produits, sites, constructeurs) | VIEWER |
| `get_machine_structure` | Hierarchie complete d'une machine | VIEWER |
| `clone_machine` | Clone une machine avec toute sa structure | GESTIONNAIRE |
| `get_dashboard_stats` | Statistiques globales | VIEWER |
| `get_entity_history` | Historique d'audit d'une entite | VIEWER |
| `get_activity_log` | Journal d'activite global | VIEWER |
### CRUD par entite
Pour chaque entite (Machine, Composant, Piece, Produit, Site, Constructeur) :
| Pattern | Exemple | Role |
|---------|---------|------|
| `list_{entite}s` | `list_machines` | VIEWER |
| `get_{entite}` | `get_machine` | VIEWER |
| `create_{entite}` | `create_machine` | GESTIONNAIRE |
| `update_{entite}` | `update_machine` | GESTIONNAIRE |
| `delete_{entite}` | `delete_machine` | GESTIONNAIRE |
### Slots
| Tool | Description | Role |
|------|-------------|------|
| `list_slots` | Lister les slots d'un composant ou piece | VIEWER |
| `update_slots` | Remplir/vider les slots | GESTIONNAIRE |
### Machine Links
| Tool | Description | Role |
|------|-------------|------|
| `list_machine_links` | Liens composant/piece/produit d'une machine | VIEWER |
| `add_machine_links` | Ajouter des liens | GESTIONNAIRE |
| `update_machine_link` | Modifier un lien | GESTIONNAIRE |
| `remove_machine_link` | Supprimer un lien | GESTIONNAIRE |
### Commentaires
| Tool | Description | Role |
|------|-------------|------|
| `list_comments` | Lister les commentaires d'une entite | VIEWER |
| `create_comment` | Creer un commentaire | VIEWER |
| `resolve_comment` | Resoudre un commentaire | GESTIONNAIRE |
| `get_unresolved_comments_count` | Nombre de commentaires non resolus | VIEWER |
### Custom Fields
| Tool | Description | Role |
|------|-------------|------|
| `list_custom_field_values` | Valeurs de champs perso d'une entite | VIEWER |
| `upsert_custom_field_values` | Creer/mettre a jour des valeurs | GESTIONNAIRE |
| `delete_custom_field_value` | Supprimer une valeur | GESTIONNAIRE |
### Documents
| Tool | Description | Role |
|------|-------------|------|
| `list_documents` | Lister les documents d'une entite | VIEWER |
| `delete_document` | Supprimer un document | GESTIONNAIRE |
> **Limitation :** L'upload de documents n'est pas supporte via MCP (protocole JSON uniquement). Utilisez l'API REST `/api/documents` (POST multipart).
### ModelTypes
| Tool | Description | Role |
|------|-------------|------|
| `list_model_types` | Lister par categorie | VIEWER |
| `get_model_type` | Detail avec skeleton requirements | VIEWER |
| `create_model_type` | Creer | GESTIONNAIRE |
| `update_model_type` | Modifier | GESTIONNAIRE |
| `delete_model_type` | Supprimer | GESTIONNAIRE |
| `sync_model_type` | Preview/sync skeleton | GESTIONNAIRE |
## Workflows guides
### Creer un composant complet
```
1. list_model_types(category: "composant") -> choisir le type
2. get_model_type(modelTypeId: "...") -> voir le skeleton
3. create_composant(name, reference, modelTypeId) -> cree + slots auto
4. search_inventory(query: "Roulement", types: "piece") -> trouver pieces
5. update_slots(slots: [{slotId, selectedPieceId}]) -> remplir
6. upsert_custom_field_values(entityType: "composant", entityId, fields: [...])
```
### Creer une machine complete (bottom-up)
```
1. Creer les produits necessaires
2. Creer les pieces (avec produits dans les slots)
3. Creer les composants (avec pieces dans les slots)
4. list_sites -> choisir le site
5. create_machine(name, siteId)
6. add_machine_links(machineId, links: [{type: "composant", entityId, quantity}])
7. upsert_custom_field_values(entityType: "machine", machineId, fields: [...])
```
## Resources MCP
| URI | Description |
|-----|-------------|
| `inventory://schema/entities` | Schema de toutes les entites |
| `inventory://roles` | Hierarchie des roles et permissions |
| `inventory://stats` | Statistiques globales |
## Roles & Permissions
```
ROLE_ADMIN > ROLE_GESTIONNAIRE > ROLE_VIEWER > ROLE_USER
```
- **VIEWER** : lecture, recherche, commentaires
- **GESTIONNAIRE** : ecriture (CRUD, slots, links, clone)
- **ADMIN** : gestion profils (via API REST uniquement)
## Troubleshooting
| Erreur | Cause | Solution |
|--------|-------|----------|
| `401 Unauthorized` | Credentials invalides | Verifier X-Profile-Id et X-Profile-Password |
| `Permission denied: ROLE_GESTIONNAIRE required` | Role insuffisant | Utiliser un profil avec le bon role |
| `Rate limited` | Trop de tentatives echouees | Attendre 1 minute |
| `Tool not found` | Tool non enregistre | Verifier que le cache est a jour (`cache:clear`) |
| `Error while executing tool` | Erreur interne | Verifier les logs et les parametres |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,871 @@
# Comment Document Attachments — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow users to attach one or more documents when creating a comment, via a single multipart/form-data request.
**Architecture:** Add a `comment` ManyToOne on Document entity (same pattern as machine/site/etc.), modify `CommentController::create()` to accept multipart/form-data with files + text fields, store files via existing `DocumentStorageService`, and update the frontend `CommentSection.vue` to include a file picker.
**Tech Stack:** Symfony 8, Doctrine, API Platform, Vue 3 Composition API, TypeScript, TailwindCSS/DaisyUI
---
### Task 1: Migration — add `comment_id` FK on `documents`
**Files:**
- Create: `migrations/Version20260323160000.php`
- [ ] **Step 1: Create the migration**
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260323160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add comment_id FK on documents table';
}
public function up(Schema $schema): void
{
$this->addSql("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'documents' AND column_name = 'comment_id') THEN ALTER TABLE documents ADD COLUMN comment_id VARCHAR(36) DEFAULT NULL; END IF; END $$");
$this->addSql("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_documents_comment') THEN ALTER TABLE documents ADD CONSTRAINT fk_documents_comment FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE; END IF; END $$");
$this->addSql("CREATE INDEX IF NOT EXISTS idx_documents_comment_id ON documents(comment_id)");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_comment');
$this->addSql('DROP INDEX IF EXISTS idx_documents_comment_id');
$this->addSql('ALTER TABLE documents DROP COLUMN IF EXISTS comment_id');
}
}
```
- [ ] **Step 2: Run the migration**
Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction`
Expected: Migration executes successfully.
- [ ] **Step 3: Update test schema**
Run: `make test-setup`
- [ ] **Step 4: Commit**
```bash
git add migrations/Version20260323160000.php
git commit -m "feat(documents) : add comment_id FK on documents table"
```
---
### Task 2: Entity updates — Document.comment + Comment.documents
**Files:**
- Modify: `src/Entity/Document.php`
- Modify: `src/Entity/Comment.php`
- [ ] **Step 1: Add `comment` ManyToOne on Document entity**
In `src/Entity/Document.php`, add after the `$site` property (around line 109):
```php
#[ORM\ManyToOne(targetEntity: Comment::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'comment_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Comment $comment = null;
```
And add getter/setter:
```php
public function getComment(): ?Comment
{
return $this->comment;
}
public function setComment(?Comment $comment): static
{
$this->comment = $comment;
return $this;
}
```
- [ ] **Step 2: Add `documents` OneToMany on Comment entity**
In `src/Entity/Comment.php`, add the import:
```php
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
```
Add property after `$updatedAt`:
```php
/** @var Collection<int, Document> */
#[ORM\OneToMany(targetEntity: Document::class, mappedBy: 'comment', cascade: ['remove'])]
private Collection $documents;
```
Initialize in constructor:
```php
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->documents = new ArrayCollection();
}
```
Add getter:
```php
/** @return Collection<int, Document> */
public function getDocuments(): Collection
{
return $this->documents;
}
```
- [ ] **Step 3: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 4: Run tests to check nothing broke**
Run: `make test`
Expected: All existing tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/Entity/Document.php src/Entity/Comment.php
git commit -m "feat(documents) : add Comment-Document relationship (ManyToOne/OneToMany)"
```
---
### Task 3: Update CommentController to accept multipart/form-data with files
**Files:**
- Modify: `src/Controller/CommentController.php`
- [ ] **Step 1: Add DocumentStorageService dependency and update create() method**
Update constructor to inject `DocumentStorageService`:
```php
use App\Entity\Document;
use App\Enum\DocumentType;
use App\Service\DocumentStorageService;
use Symfony\Component\HttpFoundation\File\UploadedFile;
```
```php
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ProfileRepository $profiles,
private readonly DocumentStorageService $storageService,
) {}
```
Replace the `create()` method body to handle both JSON and multipart:
```php
#[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);
}
// Parse fields from JSON or form-data
$contentType = $request->headers->get('Content-Type', '');
if (str_contains($contentType, 'multipart/form-data')) {
$content = trim((string) $request->request->get('content', ''));
$entityType = trim((string) $request->request->get('entityType', ''));
$entityId = trim((string) $request->request->get('entityId', ''));
$entityName = $request->request->get('entityName') ? trim((string) $request->request->get('entityName')) : null;
} else {
$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);
// Handle file uploads
/** @var UploadedFile[] $files */
$files = $request->files->all('files');
foreach ($files as $file) {
if (!$file instanceof UploadedFile || !$file->isValid()) {
continue;
}
$document = new Document();
$documentId = 'cl'.bin2hex(random_bytes(12));
$document->setId($documentId);
$document->setName($file->getClientOriginalName());
$document->setFilename($file->getClientOriginalName());
$document->setMimeType($file->getMimeType() ?: 'application/octet-stream');
$document->setSize((int) $file->getSize());
$document->setType(DocumentType::DOCUMENTATION);
$document->setComment($comment);
$extension = $this->storageService->extensionFromFilename($file->getClientOriginalName());
$relativePath = $this->storageService->storeFromPath(
$file->getPathname(),
$documentId,
$extension,
);
$document->setPath($relativePath);
$this->entityManager->persist($document);
}
$this->entityManager->flush();
return $this->json($this->normalize($comment), 201);
}
```
- [ ] **Step 2: Update normalize() to include documents**
```php
private function normalize(Comment $comment): array
{
$documents = [];
foreach ($comment->getDocuments() as $document) {
$documents[] = [
'id' => $document->getId(),
'name' => $document->getName(),
'filename' => $document->getFilename(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'type' => $document->getType()->value,
'fileUrl' => '/api/documents/'.$document->getId().'/file',
'downloadUrl' => '/api/documents/'.$document->getId().'/download',
'createdAt' => $document->getCreatedAt()->format(DateTimeInterface::ATOM),
];
}
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),
'documents' => $documents,
];
}
```
- [ ] **Step 3: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 4: Run tests**
Run: `make test`
Expected: All existing tests still pass (they use JSON, not multipart).
- [ ] **Step 5: Commit**
```bash
git add src/Controller/CommentController.php
git commit -m "feat(comments) : accept multipart/form-data with file uploads on create"
```
---
### Task 4: Update DocumentUploadProcessor and DocumentQueryController
**Files:**
- Modify: `src/State/DocumentUploadProcessor.php`
- Modify: `src/Controller/DocumentQueryController.php`
- [ ] **Step 1: Add `commentId` to DocumentUploadProcessor relation map**
In `src/State/DocumentUploadProcessor.php`, update `$relationMap` in `setRelationsFromRequest()`:
```php
$relationMap = [
'machineId' => 'Machine',
'composantId' => 'Composant',
'pieceId' => 'Piece',
'productId' => 'Product',
'siteId' => 'Site',
'commentId' => 'Comment',
];
```
- [ ] **Step 2: Add comment route to DocumentQueryController**
Add `CommentRepository` import and inject it, then add the route:
```php
use App\Repository\CommentRepository;
```
Add to constructor:
```php
private readonly CommentRepository $commentRepository,
```
Wait — `Comment` has no repository. Use the EntityManager instead. Add the route method:
```php
#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
public function listByComment(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$comment = $this->getEntityManager()->getRepository(\App\Entity\Comment::class)->find($id);
if (!$comment) {
return $this->json(['success' => false, 'error' => 'Comment not found.'], 404);
}
$documents = $this->documentRepository->findBy(['comment' => $comment]);
return $this->json($this->normalizeDocuments($documents));
}
```
Actually, the controller doesn't have `getEntityManager()`. Use `DocumentRepository` directly:
```php
#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
public function listByComment(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$documents = $this->documentRepository->findBy(['comment' => $id]);
return $this->json($this->normalizeDocuments($documents));
}
```
Wait — `findBy(['comment' => $id])` won't work with a string ID directly on a relation. Let me use the pattern from the existing code and add the Comment entity lookup. The simplest approach: inject `EntityManagerInterface`.
Actually, looking at the existing pattern more carefully, the other methods fetch the entity first and pass the object. We can use the documentRepository's entity manager. Let's just follow the exact same pattern and add a dependency. But actually, let's keep it simple — the documents table has `comment_id` column, so we can use a custom query. The simplest: just inject EntityManagerInterface.
```php
use Doctrine\ORM\EntityManagerInterface;
```
Add to constructor: `private readonly EntityManagerInterface $em,`
```php
#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
public function listByComment(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$comment = $this->em->find(\App\Entity\Comment::class, $id);
if (!$comment) {
return $this->json(['success' => false, 'error' => 'Comment not found.'], 404);
}
$documents = $this->documentRepository->findBy(['comment' => $comment]);
return $this->json($this->normalizeDocuments($documents));
}
```
- [ ] **Step 3: Update normalizeDocuments to include commentId**
Add to the normalizeDocuments return array:
```php
'commentId' => $document->getComment()?->getId(),
```
- [ ] **Step 4: Run php-cs-fixer + tests**
Run: `make php-cs-fixer-allow-risky && make test`
- [ ] **Step 5: Commit**
```bash
git add src/State/DocumentUploadProcessor.php src/Controller/DocumentQueryController.php
git commit -m "feat(documents) : add comment support in upload processor and query controller"
```
---
### Task 5: Backend tests — comment with documents
**Files:**
- Modify: `tests/Api/Controller/CommentControllerTest.php`
- [ ] **Step 1: Add test for creating comment with files**
```php
public function testCreateCommentWithFiles(): void
{
$machine = $this->createMachine('Machine A');
$client = $this->createViewerClient();
// Create a temporary file for upload
$tmpFile = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($tmpFile, 'test file content');
$uploadedFile = new \Symfony\Component\HttpFoundation\File\UploadedFile(
$tmpFile,
'test-doc.pdf',
'application/pdf',
null,
true,
);
$client->request('POST', '/api/comments', [
'headers' => ['Content-Type' => 'multipart/form-data'],
'extra' => [
'parameters' => [
'content' => 'Comment with file',
'entityType' => 'machine',
'entityId' => $machine->getId(),
'entityName' => 'Machine A',
],
'files' => [
'files' => [$uploadedFile],
],
],
]);
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame('Comment with file', $data['content']);
$this->assertCount(1, $data['documents']);
$this->assertSame('test-doc.pdf', $data['documents'][0]['filename']);
@unlink($tmpFile);
}
```
- [ ] **Step 2: Add test for creating comment with multiple files**
```php
public function testCreateCommentWithMultipleFiles(): void
{
$machine = $this->createMachine('Machine A');
$client = $this->createViewerClient();
$tmpFile1 = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($tmpFile1, 'content 1');
$tmpFile2 = tempnam(sys_get_temp_dir(), 'test_');
file_put_contents($tmpFile2, 'content 2');
$file1 = new \Symfony\Component\HttpFoundation\File\UploadedFile($tmpFile1, 'doc1.pdf', 'application/pdf', null, true);
$file2 = new \Symfony\Component\HttpFoundation\File\UploadedFile($tmpFile2, 'doc2.png', 'image/png', null, true);
$client->request('POST', '/api/comments', [
'extra' => [
'parameters' => [
'content' => 'Multiple files',
'entityType' => 'machine',
'entityId' => $machine->getId(),
],
'files' => [
'files' => [$file1, $file2],
],
],
]);
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertCount(2, $data['documents']);
@unlink($tmpFile1);
@unlink($tmpFile2);
}
```
- [ ] **Step 3: Add test that existing JSON create still works and returns empty documents array**
```php
public function testCreateCommentJsonStillReturnsDocuments(): void
{
$machine = $this->createMachine('Machine A');
$client = $this->createViewerClient();
$client->request('POST', '/api/comments', [
'json' => [
'content' => 'No files',
'entityType' => 'machine',
'entityId' => $machine->getId(),
],
]);
$this->assertResponseStatusCodeSame(201);
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertSame([], $data['documents']);
}
```
- [ ] **Step 4: Run tests**
Run: `make test`
Expected: All tests pass.
- [ ] **Step 5: Commit**
```bash
git add tests/Api/Controller/CommentControllerTest.php
git commit -m "test(comments) : add tests for comment creation with file attachments"
```
---
### Task 6: Frontend — update useComments composable
**Files:**
- Modify: `Inventory_frontend/app/composables/useComments.ts`
- [ ] **Step 1: Add document type to Comment interface**
```typescript
export interface CommentDocument {
id: string
name: string
filename: string
mimeType: string
size: number
type: string
fileUrl: string
downloadUrl: string
createdAt: string
}
export interface Comment {
id: string
content: string
entityType: string
entityId: string
entityName?: string | null
authorId: string
authorName: string
status: 'open' | 'resolved'
resolvedById?: string | null
resolvedByName?: string | null
resolvedAt?: string | null
createdAt: string
updatedAt: string
documents: CommentDocument[]
}
```
- [ ] **Step 2: Update createComment to accept files and use FormData**
Add `postFormData` to the destructured `useApi()` call:
```typescript
const { get, post, patch, postFormData, delete: del } = useApi()
```
Update `createComment`:
```typescript
const createComment = async (
entityType: string,
entityId: string,
content: string,
entityName?: string,
files?: File[],
): Promise<CommentResult> => {
loading.value = true
try {
let result
if (files && files.length > 0) {
const formData = new FormData()
formData.append('content', content)
formData.append('entityType', entityType)
formData.append('entityId', entityId)
if (entityName) formData.append('entityName', entityName)
for (const file of files) {
formData.append('files[]', file)
}
result = await postFormData('/comments', formData)
} else {
const payload: Record<string, string> = { entityType, entityId, content }
if (entityName) payload.entityName = entityName
result = await post('/comments', payload)
}
if (result.success) {
showSuccess('Commentaire ajouté')
return { success: true, data: result.data as Comment }
}
if (result.error) showError(result.error)
return { success: false, error: result.error }
} catch (error) {
const err = error as Error
showError('Impossible d\'ajouter le commentaire')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
```
- [ ] **Step 3: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 4: Commit (in frontend submodule)**
```bash
cd Inventory_frontend
git add app/composables/useComments.ts
git commit -m "feat(comments) : support file attachments in createComment"
```
---
### Task 7: Frontend — update CommentSection.vue
**Files:**
- Modify: `Inventory_frontend/app/components/CommentSection.vue`
- [ ] **Step 1: Add file input and file list display to the template**
Replace the form section (lines 22-40) with:
```vue
<!-- Formulaire d'ajout -->
<div class="space-y-2">
<div class="flex gap-2">
<textarea
v-model="newContent"
class="textarea textarea-bordered flex-1 text-sm"
rows="2"
placeholder="Ajouter un commentaire..."
:disabled="submitting"
@keydown.ctrl.enter="handleSubmit"
/>
<div class="flex flex-col gap-1 self-end">
<label
class="btn btn-ghost btn-sm btn-square tooltip tooltip-left"
data-tip="Joindre des fichiers"
>
<IconLucidePaperclip class="w-4 h-4" />
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="handleFilesSelected"
/>
</label>
<button
type="button"
class="btn btn-primary btn-sm btn-square"
:disabled="!newContent.trim() || submitting"
@click="handleSubmit"
>
<span v-if="submitting" class="loading loading-spinner loading-xs" />
<IconLucideSend v-else class="w-4 h-4" />
</button>
</div>
</div>
<!-- Selected files preview -->
<div v-if="selectedFiles.length" class="flex flex-wrap gap-1">
<span
v-for="(file, i) in selectedFiles"
:key="i"
class="badge badge-sm badge-outline gap-1"
>
<IconLucideFile class="w-3 h-3" />
{{ file.name }}
<button type="button" class="ml-1" @click="removeFile(i)">
<IconLucideX class="w-3 h-3" />
</button>
</span>
</div>
</div>
```
Add after each comment's content (`<p class="text-sm whitespace-pre-wrap">`) in both open and resolved sections:
```vue
<!-- Documents attachés -->
<div v-if="comment.documents?.length" class="flex flex-wrap gap-1 mt-1">
<a
v-for="doc in comment.documents"
:key="doc.id"
:href="`${apiBase}${doc.downloadUrl}`"
target="_blank"
class="badge badge-sm badge-ghost gap-1 hover:badge-primary"
>
<IconLucideFile class="w-3 h-3" />
{{ doc.filename }}
</a>
</div>
```
- [ ] **Step 2: Update script setup**
Add new imports:
```typescript
import IconLucidePaperclip from '~icons/lucide/paperclip'
import IconLucideFile from '~icons/lucide/file'
import IconLucideX from '~icons/lucide/x'
```
Add after existing refs:
```typescript
const selectedFiles = ref<File[]>([])
const fileInputRef = ref<HTMLInputElement | null>(null)
const apiBase = useRuntimeConfig().public.apiBase || ''
```
Add file management functions:
```typescript
const handleFilesSelected = (e: Event) => {
const input = e.target as HTMLInputElement
if (input.files) {
selectedFiles.value.push(...Array.from(input.files))
}
// Reset input so the same file can be re-selected
input.value = ''
}
const removeFile = (index: number) => {
selectedFiles.value.splice(index, 1)
}
```
Update `handleSubmit`:
```typescript
const handleSubmit = async () => {
const content = newContent.value.trim()
if (!content) return
submitting.value = true
const result = await createComment(
props.entityType,
props.entityId,
content,
props.entityName,
selectedFiles.value.length > 0 ? selectedFiles.value : undefined,
)
submitting.value = false
if (result.success) {
newContent.value = ''
selectedFiles.value = []
await loadComments()
}
}
```
- [ ] **Step 3: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 4: Commit (in frontend submodule)**
```bash
cd Inventory_frontend
git add app/components/CommentSection.vue
git commit -m "feat(comments) : add file attachment UI to CommentSection"
```
---
### Task 8: Update API Platform filter and submodule pointer
**Files:**
- Modify: `src/Entity/Document.php` (add ExistsFilter for comment)
- [ ] **Step 1: Add comment to ExistsFilter on Document entity**
Update the `ApiFilter(ExistsFilter...)` line in `Document.php`:
```php
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product', 'comment'])]
```
- [ ] **Step 2: Run php-cs-fixer + all backend tests**
Run: `make php-cs-fixer-allow-risky && make test`
- [ ] **Step 3: Commit backend**
```bash
git add src/Entity/Document.php
git commit -m "feat(documents) : add comment ExistsFilter"
```
- [ ] **Step 4: Update submodule pointer**
```bash
git add Inventory_frontend
git commit -m "chore(submodule) : update frontend pointer (comment documents feature)"
```
---
### Task 9: Manual verification
- [ ] **Step 1: Start the app**
Run: `make start`
- [ ] **Step 2: Test creating a comment without files** — should work exactly as before, response now includes `"documents": []`
- [ ] **Step 3: Test creating a comment with files** — use the paperclip button, select 1-2 files, submit. Files should appear as badges on the comment.
- [ ] **Step 4: Click a file badge** — should download the file.
- [ ] **Step 5: Run full test suite one last time**
Run: `make test`

View File

@@ -0,0 +1,809 @@
# Document Types Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a `type` enum field to documents (documentation, devis, facture, plan, photo, autre) with classification at upload and inline editing afterward.
**Architecture:** New PHP enum `DocumentType` + column on `documents` table. Migration classifies existing rows by mimeType. Frontend gets a type select at upload, a badge in document lists, and a mini-modal for editing name+type via PATCH.
**Tech Stack:** Symfony 8, API Platform, Doctrine, PHP 8.4 enums, Nuxt 4, Vue 3, DaisyUI 5
---
## File Structure
### Backend (create)
- `src/Enum/DocumentType.php` — PHP backed enum with 6 values
- `migrations/VersionXXX_add_document_type.php` — ALTER TABLE + data classification
### Backend (modify)
- `src/Entity/Document.php` — add `type` column + Patch operation
- `src/State/DocumentUploadProcessor.php` — accept `type` from FormData
- `src/Controller/DocumentQueryController.php` — add `type` to `normalizeDocuments()`
### Frontend (create)
- `Inventory_frontend/app/shared/documentTypes.ts` — type constants + labels
- `Inventory_frontend/app/components/DocumentEditModal.vue` — mini-modal for editing name+type
### Frontend (modify)
- `Inventory_frontend/app/composables/useDocuments.ts` — add `type` to interface + `updateDocument()` method
- `Inventory_frontend/app/components/DocumentUpload.vue` — add type select
- `Inventory_frontend/app/components/common/DocumentListInline.vue` — add type badge + edit button
- `Inventory_frontend/app/composables/useEntityDocuments.ts` — add `updateDocument` delegation
- `Inventory_frontend/app/pages/documents.vue` — add type column + edit button
---
### Task 1: PHP Enum + Entity Column
**Files:**
- Create: `src/Enum/DocumentType.php`
- Modify: `src/Entity/Document.php:31-54` (API resource), `src/Entity/Document.php:107-113` (add column after site)
- [ ] **Step 1: Create the DocumentType enum**
```php
// src/Enum/DocumentType.php
<?php
declare(strict_types=1);
namespace App\Enum;
enum DocumentType: string
{
case DOCUMENTATION = 'documentation';
case DEVIS = 'devis';
case FACTURE = 'facture';
case PLAN = 'plan';
case PHOTO = 'photo';
case AUTRE = 'autre';
}
```
- [ ] **Step 2: Add type column to Document entity**
In `src/Entity/Document.php`, add after the `$site` property (line ~106):
```php
#[ORM\Column(type: Types::STRING, length: 20, enumType: DocumentType::class)]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private DocumentType $type = DocumentType::DOCUMENTATION;
```
Add getter/setter:
```php
public function getType(): DocumentType
{
return $this->type;
}
public function setType(DocumentType $type): static
{
$this->type = $type;
return $this;
}
```
Add the import at top: `use App\Enum\DocumentType;`
- [ ] **Step 3: Add Patch operation to Document API resource**
In the `operations` array of `#[ApiResource(...)]`, add after the existing `Put`:
```php
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
```
Add the import: `use ApiPlatform\Metadata\Patch;`
- [ ] **Step 4: Run cs-fixer and verify**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 5: Commit**
```bash
git add src/Enum/DocumentType.php src/Entity/Document.php
git commit -m "feat(documents) : add DocumentType enum and type column on entity"
```
---
### Task 2: Migration
**Files:**
- Create: new migration file via Doctrine
- [ ] **Step 1: Generate migration**
Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff`
This will generate a migration. Then edit it to add the data classification.
- [ ] **Step 2: Edit migration to classify existing documents**
The generated migration will have the `ALTER TABLE` for adding the column. After the column add, append:
```sql
UPDATE documents SET type = 'photo' WHERE mimetype LIKE 'image/%';
UPDATE documents SET type = 'autre' WHERE type = 'documentation' AND mimetype NOT LIKE 'application/pdf' AND mimetype NOT LIKE 'image/%';
```
Use `IF NOT EXISTS` pattern consistent with other migrations:
```php
public function up(Schema $schema): void
{
$this->addSql("DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'documents' AND column_name = 'type') THEN ALTER TABLE documents ADD COLUMN type VARCHAR(20) NOT NULL DEFAULT 'documentation'; END IF; END $$");
$this->addSql("UPDATE documents SET type = 'photo' WHERE mimetype LIKE 'image/%'");
$this->addSql("UPDATE documents SET type = 'autre' WHERE type = 'documentation' AND mimetype NOT LIKE 'application/pdf' AND mimetype NOT LIKE 'image/%'");
}
```
- [ ] **Step 3: Run migration**
Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction`
- [ ] **Step 4: Verify data classification**
Run: `docker exec -u www-data php-inventory-apache php bin/console dbal:run-sql "SELECT type, COUNT(*) FROM documents GROUP BY type"`
- [ ] **Step 5: Commit**
```bash
git add migrations/
git commit -m "feat(documents) : add migration for type column with data classification"
```
---
### Task 3: Backend — Upload Processor + Query Controller
**Files:**
- Modify: `src/State/DocumentUploadProcessor.php:66-77`
- Modify: `src/Controller/DocumentQueryController.php:110-127`
- [ ] **Step 1: Accept type in DocumentUploadProcessor**
In `handleMultipartUpload()`, after `$document->setSize((int) $size);` (line ~77), add:
```php
// Document type from form field (default: documentation)
$typeValue = $request->request->get('type', 'documentation');
$docType = DocumentType::tryFrom($typeValue) ?? DocumentType::DOCUMENTATION;
$document->setType($docType);
```
Add import: `use App\Enum\DocumentType;`
- [ ] **Step 2: Add type to DocumentQueryController normalizeDocuments**
In `normalizeDocuments()`, add `'type'` to the returned array after `'productId'`:
```php
'type' => $document->getType()->value,
```
- [ ] **Step 3: Write test for PATCH type update**
In `tests/Api/Entity/DocumentTest.php`, add:
```php
public function testPatchType(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('documents', $doc->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['type' => 'devis'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['type' => 'devis']);
}
public function testPatchNameAndType(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('documents', $doc->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'new-name', 'type' => 'facture'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['name' => 'new-name', 'type' => 'facture']);
}
public function testGetItemIncludesType(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createViewerClient();
$client->request('GET', self::iri('documents', $doc->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['type' => 'documentation']);
}
public function testViewerCannotPatch(): void
{
$doc = $this->createDocumentInDb();
$client = $this->createViewerClient();
$client->request('PATCH', self::iri('documents', $doc->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['type' => 'devis'],
]);
$this->assertResponseStatusCodeSame(403);
}
```
- [ ] **Step 4: Run tests**
Run: `make test FILES=tests/Api/Entity/DocumentTest.php`
Expected: all tests pass
- [ ] **Step 5: Run cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 6: Commit**
```bash
git add src/State/DocumentUploadProcessor.php src/Controller/DocumentQueryController.php tests/Api/Entity/DocumentTest.php
git commit -m "feat(documents) : accept type on upload + expose in query controller + PATCH support"
```
---
### Task 4: Frontend — Type Constants + Document Interface
**Files:**
- Create: `Inventory_frontend/app/shared/documentTypes.ts`
- Modify: `Inventory_frontend/app/composables/useDocuments.ts:6-27` (Document interface), `useDocuments.ts:205-253` (upload)
- [ ] **Step 1: Create documentTypes.ts**
```typescript
// Inventory_frontend/app/shared/documentTypes.ts
export const DOCUMENT_TYPES = [
{ value: 'documentation', label: 'Documentation' },
{ value: 'devis', label: 'Devis' },
{ value: 'facture', label: 'Facture' },
{ value: 'plan', label: 'Plan' },
{ value: 'photo', label: 'Photo' },
{ value: 'autre', label: 'Autre' },
] as const
export type DocumentTypeValue = (typeof DOCUMENT_TYPES)[number]['value']
export const getDocumentTypeLabel = (value: string): string => {
const found = DOCUMENT_TYPES.find((t) => t.value === value)
return found?.label ?? value
}
```
- [ ] **Step 2: Add type to Document interface and UploadContext**
In `useDocuments.ts`, add to `Document` interface after `downloadUrl`:
```typescript
type?: string
```
Add to `UploadContext` interface:
```typescript
type?: string
```
- [ ] **Step 3: Add type to uploadDocuments FormData**
In `uploadDocuments()`, after `formData.append('name', file.name)` (line ~220), add:
```typescript
if (context.type) formData.append('type', context.type)
```
- [ ] **Step 4: Add updateDocument method**
In `useDocuments()`, before the `return` block, add:
```typescript
const updateDocument = async (
id: string,
data: { name?: string; type?: string },
): Promise<DocumentResult> => {
loading.value = true
try {
const result = await patch(`/documents/${id}`, data)
if (result.success && result.data) {
const updated = result.data as Document
const index = documents.value.findIndex((doc) => doc.id === id)
if (index !== -1) {
documents.value[index] = { ...documents.value[index], ...updated }
}
showSuccess('Document mis à jour')
return { success: true, data: updated }
}
if (result.error) showError(result.error)
return result as DocumentResult
} catch (error) {
const err = error as Error
showError('Impossible de mettre à jour le document')
return { success: false, error: err.message }
} finally {
loading.value = false
}
}
```
Add `patch` to the destructured `useApi()` call at the top of the composable:
```typescript
const { get, patch, postFormData, delete: del } = useApi()
```
Add `updateDocument` to the return object.
- [ ] **Step 5: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 6: Commit frontend**
```bash
cd Inventory_frontend
git add app/shared/documentTypes.ts app/composables/useDocuments.ts
git commit -m "feat(documents) : add document type constants and updateDocument method"
```
---
### Task 5: Frontend — DocumentUpload Type Select
**Files:**
- Modify: `Inventory_frontend/app/components/DocumentUpload.vue`
- [ ] **Step 1: Add type prop and select to DocumentUpload**
Add prop:
```javascript
documentType: {
type: String,
default: 'documentation'
}
```
Add emit:
```javascript
'update:documentType'
```
Add a select dropdown in the template, before the file list (`<ul>`), after the button area:
```html
<div class="w-full max-w-xs mt-2">
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70">
Type de document
</label>
<select
class="select select-bordered select-sm w-full mt-1"
:value="documentType"
@change="$emit('update:documentType', ($event.target as HTMLSelectElement).value)"
>
<option v-for="t in documentTypes" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
</div>
```
Import the types:
```javascript
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
const documentTypes = DOCUMENT_TYPES
```
Note: since DocumentUpload uses `<script setup>` without `lang="ts"`, use `@change="$emit('update:documentType', $event.target.value)"` (no cast).
- [ ] **Step 2: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 3: Commit**
```bash
cd Inventory_frontend
git add app/components/DocumentUpload.vue
git commit -m "feat(documents) : add type select to DocumentUpload component"
```
---
### Task 6: Frontend — DocumentEditModal
**Files:**
- Create: `Inventory_frontend/app/components/DocumentEditModal.vue`
- [ ] **Step 1: Create DocumentEditModal component**
```vue
<template>
<Teleport to="body">
<div v-if="visible" class="modal modal-open" @click.self="$emit('close')">
<div class="modal-box max-w-sm">
<h3 class="font-bold text-lg mb-4">
Modifier le document
</h3>
<div class="space-y-4">
<label class="form-control w-full">
<div class="label">
<span class="label-text">Nom</span>
</div>
<input
v-model="form.name"
type="text"
class="input input-bordered input-sm md:input-md w-full"
>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Type</span>
</div>
<select
v-model="form.type"
class="select select-bordered select-sm md:select-md w-full"
>
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
</label>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="$emit('close')">
Annuler
</button>
<button
type="button"
class="btn btn-primary btn-sm md:btn-md"
:disabled="saving"
@click="save"
>
<span v-if="saving" class="loading loading-spinner loading-xs" />
Sauvegarder
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { reactive, watch, ref } from 'vue'
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
import type { Document } from '~/composables/useDocuments'
const props = defineProps<{
visible: boolean
document: Document | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'updated', data: { name: string; type: string }): void
}>()
const form = reactive({ name: '', type: 'documentation' })
const saving = ref(false)
watch(
() => props.document,
(doc) => {
if (doc) {
form.name = doc.name || ''
form.type = doc.type || 'documentation'
}
},
{ immediate: true },
)
const save = () => {
if (!form.name.trim()) return
saving.value = true
emit('updated', { name: form.name.trim(), type: form.type })
saving.value = false
}
</script>
```
- [ ] **Step 2: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 3: Commit**
```bash
cd Inventory_frontend
git add app/components/DocumentEditModal.vue
git commit -m "feat(documents) : add DocumentEditModal component"
```
---
### Task 7: Frontend — DocumentListInline + Type Badge + Edit Button
**Files:**
- Modify: `Inventory_frontend/app/components/common/DocumentListInline.vue`
- Modify: `Inventory_frontend/app/composables/useEntityDocuments.ts`
- [ ] **Step 1: Add type badge and edit button to DocumentListInline**
In the template, after the document name `<div>` (line ~33-40), add a badge for the type:
```html
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(document.type || 'documentation') }}</span>
```
In the actions area (line ~42-68), add an edit button before "Consulter":
```html
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
title="Modifier"
@click="$emit('edit', document)"
>
Modifier
</button>
```
Add props:
```typescript
canEdit?: boolean
```
Default: `false`
Add emit:
```typescript
(e: 'edit', document: Document): void
```
Add import:
```typescript
import { getDocumentTypeLabel } from '~/shared/documentTypes'
```
- [ ] **Step 2: Add updateDocument to useEntityDocuments**
In `useEntityDocuments.ts`, add `updateDocument` from useDocuments:
```typescript
const { uploadDocuments, deleteDocument, updateDocument } = useDocuments()
```
Add method:
```typescript
const editDocument = async (id: string, data: { name?: string; type?: string }) => {
const result: any = await updateDocument(id, data)
if (result.success) {
const e = entity()
const docs = e.documents || []
const index = docs.findIndex((doc: any) => doc.id === id)
if (index !== -1) {
docs[index] = { ...docs[index], ...data }
}
}
return result
}
```
Add `editDocument` to the return object.
- [ ] **Step 3: Run lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 4: Commit**
```bash
cd Inventory_frontend
git add app/components/common/DocumentListInline.vue app/composables/useEntityDocuments.ts
git commit -m "feat(documents) : add type badge and edit button to DocumentListInline"
```
---
### Task 8: Frontend — Wire Edit Modal in Entity Pages
**Files:**
- Modify: `Inventory_frontend/app/components/ComponentItem.vue`
- Modify: `Inventory_frontend/app/components/PieceItem.vue`
- Modify: `Inventory_frontend/app/pages/pieces/[id]/edit.vue`
- Modify: `Inventory_frontend/app/pages/component/[id]/edit.vue`
- Modify: `Inventory_frontend/app/pages/product/[id]/edit.vue`
- [ ] **Step 1: Wire in ComponentItem and PieceItem**
For each of `ComponentItem.vue` and `PieceItem.vue`:
1. Add `editDocument` from the `useEntityDocuments` return
2. Add state refs for the edit modal:
```typescript
const editingDocument = ref<any>(null)
const editModalVisible = ref(false)
```
3. Add handler:
```typescript
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name: string; type: string }) => {
if (!editingDocument.value?.id) return
await editDocument(editingDocument.value.id, data)
editModalVisible.value = false
editingDocument.value = null
}
```
4. Add `DocumentEditModal` in the template
5. Pass `:can-edit="isEditMode"` and `@edit="openEditModal"` to `DocumentListInline`
- [ ] **Step 2: Wire in edit pages (pieces/edit, component/edit, product/edit)**
Same pattern: add edit modal state, wire `DocumentListInline` with `:can-edit` and `@edit`, add `DocumentEditModal`.
- [ ] **Step 3: Wire type select in upload**
In pages that use `DocumentUpload`, add a `documentType` ref and pass it:
```html
<DocumentUpload
v-model="selectedFiles"
v-model:document-type="uploadDocType"
...
/>
```
Pass `type: uploadDocType.value` in the upload context when calling `handleFilesAdded` or `uploadDocuments`.
- [ ] **Step 4: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 5: Commit**
```bash
cd Inventory_frontend
git add app/components/ app/pages/
git commit -m "feat(documents) : wire DocumentEditModal and type select in all entity pages"
```
---
### Task 9: Frontend — Documents Global Page
**Files:**
- Modify: `Inventory_frontend/app/pages/documents.vue`
- [ ] **Step 1: Add type column to DataTable**
In the `columns` array, add after `mimeType`:
```typescript
{ key: 'type', label: 'Type' },
```
Add the cell template:
```html
<template #cell-type="{ row }">
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(row.type || 'documentation') }}</span>
</template>
```
- [ ] **Step 2: Add edit button + modal**
Add an edit button in the `#cell-actions` template slot:
```html
<button
v-if="canEdit"
class="btn btn-ghost btn-xs"
type="button"
@click="openEditModal(row)"
>
Modifier
</button>
```
Add `DocumentEditModal` component in the template. Add the edit state + handler logic (same pattern as Task 8). Use `useDocuments().updateDocument` directly.
Import `usePermissions` to derive `canEdit` from the user's role (ROLE_GESTIONNAIRE or above).
- [ ] **Step 3: Add type filter**
Add a type filter select next to the existing "Rattachement" filter:
```html
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70" for="doc-type-filter">
Type
</label>
<select
id="doc-type-filter"
v-model="typeFilter"
class="select select-bordered select-sm"
@change="table.handleFilterChange"
>
<option value="all">Tous</option>
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
```
Pass `typeFilter` to `fetchDocuments` → `loadDocuments` as a new filter param, and in `useDocuments.loadDocuments` add `params.set('type', typeFilter)` when not `'all'`.
- [ ] **Step 4: Run lint + typecheck**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
- [ ] **Step 5: Commit**
```bash
cd Inventory_frontend
git add app/pages/documents.vue app/composables/useDocuments.ts
git commit -m "feat(documents) : add type column, filter, and edit to documents page"
```
---
### Task 10: Final — Submodule Pointer + Verification
**Files:**
- Main repo: update submodule pointer
- [ ] **Step 1: Run full backend tests**
Run: `make test`
Expected: all tests pass
- [ ] **Step 2: Run full frontend checks**
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck && npm run build`
Expected: 0 errors
- [ ] **Step 3: Manual verification**
1. Go to `/pieces/{id}/edit` — verify type badge on existing docs, edit modal works
2. Go to `/component/{id}/edit` — same verification
3. Upload a new document — verify type select appears, type is saved
4. Go to `/documents` — verify type column, filter, edit button
5. Check that existing PDFs show "Documentation", images show "Photo", others show "Autre"
- [ ] **Step 4: Commit submodule pointer**
```bash
cd /home/matthieu/dev_malio/Inventory
git add Inventory_frontend
git commit -m "chore(submodule) : update frontend pointer (document types feature)"
```

View File

@@ -0,0 +1,418 @@
# Fix Data-Loss Bugs — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix all bugs that cause silent data loss in the composant/piece/product/skeleton/custom-fields data model.
**Architecture:** 6 independent fixes across backend (PHP) and frontend (TS). Each task is self-contained and can be committed independently. Backend fixes come first because they protect data integrity at the source.
**Tech Stack:** Symfony 8 / PHP 8.4 / PostgreSQL 16 / Nuxt 4 / Vue 3 / TypeScript
---
## File Map
| Task | Action | File |
|------|--------|------|
| T1 | Modify | `src/Controller/MachineStructureController.php:174-195` |
| T2 | Modify | `src/Controller/ComposantPieceSlotController.php:41-47` |
| T3 | Modify | `src/Service/ModelTypeCategoryConversionService.php:195-236` |
| T3 | Modify | `src/Service/ModelTypeCategoryConversionService.php:340-405` |
| T4 | Modify | `src/Controller/CustomFieldValueController.php:199-211` |
| T5 | Modify | `Inventory_frontend/app/composables/useComponentEdit.ts:398-405` |
| T5 | Modify | `Inventory_frontend/app/composables/usePieceEdit.ts:407-414` |
| T6 | Modify | `Inventory_frontend/app/composables/useComponentCreate.ts` (same pattern if present) |
---
### Task 1: Clone machine — CustomFieldValue pointe vers les CustomField de la source
**Probleme:** `cloneCustomFields` clone les `CustomField` (definitions) pour la target, mais les `CustomFieldValue` (valeurs) restent liees aux `CustomField` de la source. Supprimer la source cascade-delete les valeurs du clone.
**Files:**
- Modify: `src/Controller/MachineStructureController.php:174-195`
- Test: `tests/Api/Controller/MachineStructureControllerTest.php` (clone test existant)
- [ ] **Step 1: Write the failing test**
Dans le test de clone existant, ajouter une assertion : apres clone, verifier que chaque `CustomFieldValue` de la machine clonee pointe vers un `CustomField` dont `machineId` est l'ID de la machine clonee (pas la source).
```php
// After clone, fetch the cloned machine's custom field values
$clonedValues = $em->getRepository(CustomFieldValue::class)->findBy(['machine' => $clonedMachine]);
foreach ($clonedValues as $cfv) {
$this->assertSame(
$clonedMachine->getId(),
$cfv->getCustomField()->getMachine()->getId(),
'Cloned CustomFieldValue must reference the cloned CustomField, not the source'
);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `make test FILES=tests/Api/Controller/MachineStructureControllerTest.php`
Expected: FAIL — cloned values reference source machine's custom fields
- [ ] **Step 3: Implement the fix**
In `cloneCustomFields`, build a map `$oldCfId => $newCf` in the first loop, then use it in the second loop:
```php
private function cloneCustomFields(Machine $source, Machine $target): void
{
$cfMap = [];
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);
$cfMap[$cf->getId()] = $newCf;
}
foreach ($source->getCustomFieldValues() as $cfv) {
$originalCf = $cfv->getCustomField();
$newCf = $cfMap[$originalCf->getId()] ?? null;
if (!$newCf) {
continue;
}
$newValue = new CustomFieldValue();
$newValue->setMachine($target);
$newValue->setCustomField($newCf);
$newValue->setValue($cfv->getValue());
$this->entityManager->persist($newValue);
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `make test FILES=tests/Api/Controller/MachineStructureControllerTest.php`
Expected: PASS
- [ ] **Step 5: Lint**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 6: Commit**
```bash
git add src/Controller/MachineStructureController.php tests/Api/Controller/MachineStructureControllerTest.php
git commit -m "fix(clone) : custom field values reference cloned definitions, not source"
```
---
### Task 2: ComposantPieceSlot PATCH — pas de validation du type de piece ni 404
**Probleme:** On peut assigner n'importe quelle piece dans un slot, meme si son type ne correspond pas au type requis par le squelette. Si la piece n'existe pas, `null` est silencieusement mis.
**Files:**
- Modify: `src/Controller/ComposantPieceSlotController.php:41-47`
- Test: `tests/Api/Controller/ComposantPieceSlotControllerTest.php` (creer si absent)
- [ ] **Step 1: Write the failing test — piece not found returns 404**
```php
public function testPatchSlotWithNonExistentPieceReturns404(): void
{
$client = $this->createGestionnaireClient();
// Create a slot via fixtures
$slot = $this->createComposantPieceSlot();
$client->request('PATCH', '/api/composant-piece-slots/' . $slot->getId(), [
'json' => ['selectedPieceId' => 'cl_nonexistent_id'],
'headers' => ['Content-Type' => 'application/json'],
]);
$this->assertResponseStatusCodeSame(404);
}
```
- [ ] **Step 2: Write the failing test — wrong piece type returns 422**
```php
public function testPatchSlotWithWrongPieceTypeReturns422(): void
{
$client = $this->createGestionnaireClient();
$typeA = $this->createModelType(['category' => 'piece', 'name' => 'Type A']);
$typeB = $this->createModelType(['category' => 'piece', 'name' => 'Type B']);
$slot = $this->createComposantPieceSlot(['typePiece' => $typeA]);
$wrongPiece = $this->createPiece(['typePiece' => $typeB]);
$client->request('PATCH', '/api/composant-piece-slots/' . $slot->getId(), [
'json' => ['selectedPieceId' => $wrongPiece->getId()],
'headers' => ['Content-Type' => 'application/json'],
]);
$this->assertResponseStatusCodeSame(422);
}
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `make test FILES=tests/Api/Controller/ComposantPieceSlotControllerTest.php`
Expected: FAIL
- [ ] **Step 4: Implement the fix**
```php
if (array_key_exists('selectedPieceId', $payload)) {
if (null === $payload['selectedPieceId']) {
$slot->setSelectedPiece(null);
} else {
$piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']);
if (!$piece) {
return $this->json(['success' => false, 'error' => 'Pièce introuvable.'], 404);
}
$slotTypePiece = $slot->getTypePiece();
if ($slotTypePiece && $piece->getTypePiece()?->getId() !== $slotTypePiece->getId()) {
return $this->json([
'success' => false,
'error' => sprintf(
'La pièce doit être de type « %s ».',
$slotTypePiece->getName(),
),
], 422);
}
$slot->setSelectedPiece($piece);
}
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `make test FILES=tests/Api/Controller/ComposantPieceSlotControllerTest.php`
Expected: PASS
- [ ] **Step 6: Lint + commit**
```bash
make php-cs-fixer-allow-risky
git add src/Controller/ComposantPieceSlotController.php tests/Api/Controller/ComposantPieceSlotControllerTest.php
git commit -m "fix(slots) : validate piece type matches slot requirement + 404 on missing piece"
```
---
### Task 3: Conversion de categorie — slots supprimes sans verification + skeleton requirements orphelins
**Probleme A:** `checkComponentToPiece` verifie `structure IS NOT NULL` (ancien JSON) mais les donnees sont dans les tables de slots. Le check passe toujours et les slots sont cascade-deleted.
**Probleme B:** Apres conversion, les `skeleton_piece_requirements`, `skeleton_product_requirements`, `skeleton_subcomponent_requirements` de l'ancien type ne sont pas supprimes.
**Files:**
- Modify: `src/Service/ModelTypeCategoryConversionService.php:195-236` (check)
- Modify: `src/Service/ModelTypeCategoryConversionService.php:340-405` (convert)
- [ ] **Step 1: Fix `checkComponentToPiece` — ajouter le check sur les tables de slots**
Apres le check `structure IS NOT NULL` existant (qui reste pour compatibilite), ajouter :
```php
// Check slot tables for actual data (post-normalization architecture)
$slotsWithData = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM composant_piece_slots cps
JOIN composants c ON cps.composantid = c.id
WHERE c.typecomposantid = :id AND cps.selectedpieceid IS NOT NULL',
['id' => $modelTypeId],
);
$subSlots = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM composant_subcomponent_slots css
JOIN composants c ON css.composantid = c.id
WHERE c.typecomposantid = :id AND css.selectedcomposantid IS NOT NULL',
['id' => $modelTypeId],
);
if ($slotsWithData > 0 || $subSlots > 0) {
$parts = [];
if ($slotsWithData > 0) {
$parts[] = sprintf('%d slot(s) pièce rempli(s)', $slotsWithData);
}
if ($subSlots > 0) {
$parts[] = sprintf('%d slot(s) sous-composant rempli(s)', $subSlots);
}
$blockers[] = sprintf(
'Des composants ont des données dans leurs slots : %s.',
implode(', ', $parts),
);
}
```
- [ ] **Step 2: Fix `convertComponentToPiece` — nettoyer les skeleton requirements avant le changement de categorie**
Ajouter entre l'etape 6 (DELETE composants) et l'etape 7 (UPDATE model_types) :
```php
// 6b. Clean up skeleton requirements that belong to COMPONENT category
$this->connection->executeStatement(
'DELETE FROM skeleton_piece_requirements WHERE modeltypeid = :id',
['id' => $modelTypeId],
);
$this->connection->executeStatement(
'DELETE FROM skeleton_subcomponent_requirements WHERE modeltypeid = :id',
['id' => $modelTypeId],
);
// Note: skeleton_product_requirements are kept — valid for both COMPONENT and PIECE categories
```
- [ ] **Step 3: Fix `convertPieceToComponent` — meme nettoyage dans l'autre sens**
Les `skeleton_product_requirements` qui appartenaient au type PIECE restent. Aucun nettoyage specifique necessaire car les product requirements sont valides pour les deux types. Mais verifier que la methode existe et n'a pas le meme probleme.
- [ ] **Step 4: Run all conversion tests**
Run: `make test FILES=tests/Api/Controller/ModelTypeConversionControllerTest.php`
Si absent: `make test` (tous les tests)
Expected: PASS
- [ ] **Step 5: Lint + commit**
```bash
make php-cs-fixer-allow-risky
git add src/Service/ModelTypeCategoryConversionService.php
git commit -m "fix(conversion) : block conversion when slots have data + clean skeleton requirements"
```
---
### Task 4: CustomFieldValueController — cree des CustomField orphelins sans FK
**Probleme:** Quand `customFieldId` est absent et `customFieldName` est fourni, un nouveau `CustomField` est cree sans etre rattache a aucune entite (ni machine, ni modelType). La ligne est invisible et inutile.
**Files:**
- Modify: `src/Controller/CustomFieldValueController.php:199-211`
- [ ] **Step 1: Implement the fix**
La methode `resolveCustomField` cree un `CustomField` orphelin. Il faut utiliser le `target` (deja resolu) pour rattacher le champ au bon parent. Le plus simple : deplacer la creation du CustomField apres la resolution du target, ou passer le target en parametre.
Option retenue : retourner un array `['customField' => $cf, 'isNew' => true]` et laisser `applyTarget` gerer le rattachement, OU plus simplement, interdire la creation ad-hoc et retourner une erreur 400 quand le champ n'existe pas.
L'approche la plus sure (pas de CustomField orphelin) :
```php
// In resolveCustomField, replace the auto-creation block with:
$customFieldName = isset($payload['customFieldName']) ? trim((string) $payload['customFieldName']) : '';
if ('' === $customFieldName) {
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
}
// Try to find existing custom field by name for the target entity
$target = $this->resolveTarget($payload);
if ($target instanceof JsonResponse) {
return $this->json(['success' => false, 'error' => 'Cannot create custom field without a valid target entity.'], 400);
}
$existingField = $this->customFieldRepository->findOneBy(['name' => $customFieldName]);
if ($existingField) {
return $existingField;
}
return $this->json(['success' => false, 'error' => sprintf('Custom field "%s" not found. Create it explicitly first.', $customFieldName)], 404);
```
**Alternative plus conservative** si le frontend depend de cette auto-creation : garder la creation mais rattacher au target. Cela necessite de refactorer le flow pour passer le target a `resolveCustomField`. Choisir selon le frontend.
- [ ] **Step 2: Run tests**
Run: `make test`
Expected: PASS (verifier qu'aucun test ne depend de l'auto-creation)
- [ ] **Step 3: Lint + commit**
```bash
make php-cs-fixer-allow-risky
git add src/Controller/CustomFieldValueController.php
git commit -m "fix(custom-fields) : prevent creation of orphan CustomField without target entity"
```
---
### Task 5: Frontend — custom fields definition lookup au mauvais chemin
**Probleme:** `useComponentEdit` passe `typeComposant.customFields` (pas serialise par l'API) au lieu de `typeComposant.structure.customFields`. Idem `usePieceEdit` avec `typePiece.pieceCustomFields` au lieu de `typePiece.structure.customFields`.
Consequence : le `definitionMap` est toujours vide, les champs perso sans `customFieldId` existant ne trouvent pas leur definition et sont envoyes sans `definitionId` (fallback sur metadata = CustomField orphelin cote backend = Task 4).
**Files:**
- Modify: `Inventory_frontend/app/composables/useComponentEdit.ts:401-403`
- Modify: `Inventory_frontend/app/composables/usePieceEdit.ts:410-412`
- [ ] **Step 1: Fix useComponentEdit.ts**
Ligne 401-403, remplacer :
```ts
[
updatedComponent?.typeComposant?.customFields,
]
```
par :
```ts
[
updatedComponent?.typeComposant?.structure?.customFields,
]
```
- [ ] **Step 2: Fix usePieceEdit.ts**
Ligne 410-412, remplacer :
```ts
[
updatedPiece?.typePiece?.pieceCustomFields,
]
```
par :
```ts
[
updatedPiece?.typePiece?.structure?.customFields,
]
```
- [ ] **Step 3: Verifier le meme pattern dans les autres fichiers**
Verifier `useComponentCreate.ts`, `pieces/create.vue`, `product/[id]/edit.vue` pour le meme probleme.
- [ ] **Step 4: Lint + typecheck**
```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 5: Commit**
```bash
cd Inventory_frontend
git add app/composables/useComponentEdit.ts app/composables/usePieceEdit.ts
git commit -m "fix(custom-fields) : use structure.customFields path for definition lookup"
```
---
### Task 6 (bonus): Verifier et corriger les memes patterns dans create flows
- [ ] **Step 1:** Grep `_saveCustomFieldValues` dans tous les fichiers et verifier que chaque appel passe `structure.customFields` et non `customFields` ou `pieceCustomFields` directement.
- [ ] **Step 2:** Corriger si necessaire, lint, commit.
---
## Ordre d'execution recommande
1. **T1** (clone) — fix isole, pas de dependance
2. **T2** (slots validation) — fix isole
3. **T5** (frontend custom fields path) — fix isole
4. **T4** (orphan CustomField) — depend de T5 pour comprendre si le frontend utilise l'auto-creation
5. **T3** (conversion) — le plus complexe, faire en dernier
6. **T6** (bonus verification)

View File

@@ -0,0 +1,409 @@
# Parc Machines UX Improvements — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Multi-select site filter with checkboxes, alphabetical sorting on Parc Machines, and OR search (name/reference) on catalog pages.
**Architecture:** Frontend-only changes for tasks 1-2 (Vue reactivity + computed sort). Backend Doctrine Extension for task 3 that intercepts `?q=` parameter and builds an OR clause across `name` and `reference` fields, with corresponding frontend composable changes.
**Tech Stack:** Vue 3 (reactive Set), DaisyUI 5 checkboxes, Symfony/API Platform Doctrine ORM Extension, PHPUnit
**Spec:** `docs/superpowers/specs/2026-03-23-parc-machines-ux-design.md`
---
### Task 1: Multi-select site checkboxes on Parc Machines
**Files:**
- Modify: `Inventory_frontend/app/pages/machines/index.vue`
- [ ] **Step 1: Replace `selectedSite` ref with reactive Set**
In `<script setup>`, replace:
```js
const selectedSite = ref('')
```
with:
```js
const selectedSites = reactive(new Set())
```
- [ ] **Step 2: Replace `<select>` with checkboxes in template**
Replace the site filter `<div class="form-control">` block (the one containing the `<select>`) with:
```vue
<div class="form-control">
<label class="label">
<span class="label-text">Sites</span>
</label>
<div class="flex flex-wrap gap-3">
<label
v-for="site in sites"
:key="site.id"
class="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="selectedSites.has(site.id)"
@change="selectedSites.has(site.id) ? selectedSites.delete(site.id) : selectedSites.add(site.id)"
>
<span class="text-sm">{{ site.name }}</span>
</label>
</div>
</div>
```
- [ ] **Step 3: Update `filteredMachines` computed for multi-select**
Replace:
```js
if (selectedSite.value) {
filtered = filtered.filter(machine => machine.siteId === selectedSite.value)
}
```
with:
```js
if (selectedSites.size > 0) {
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
}
```
- [ ] **Step 4: Clean up unused `ref` import if needed**
Check if `ref` is still used elsewhere in the file (it is — `searchQuery` uses it). If so, keep it. Remove only if no longer referenced.
- [ ] **Step 5: Add `reactive` to imports**
Add `reactive` to the import from `vue`:
```js
import { ref, reactive, computed, onMounted } from 'vue'
```
- [ ] **Step 6: Verify in browser**
Open `http://localhost:3001/machines`. Confirm:
- Checkboxes appear for each site
- Checking one site filters machines to that site only
- Checking multiple sites shows machines from all selected sites
- Unchecking all shows all machines
- [ ] **Step 7: Run frontend lint**
Run: `cd Inventory_frontend && npm run lint:fix`
---
### Task 2: Alphabetical sorting on Parc Machines
**Files:**
- Modify: `Inventory_frontend/app/pages/machines/index.vue`
- [ ] **Step 1: Add sort to `filteredMachines` computed**
At the end of the `filteredMachines` computed, just before `return filtered`, add:
```js
filtered = [...filtered].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr')
)
```
The full computed should now be:
```js
const filteredMachines = computed(() => {
let filtered = enrichedMachines.value
if (selectedSites.size > 0) {
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
}
if (searchQuery.value.trim()) {
const term = searchQuery.value.trim().toLowerCase()
filtered = filtered.filter(machine =>
machine.name?.toLowerCase().includes(term)
|| machine.reference?.toLowerCase().includes(term),
)
}
filtered = [...filtered].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr')
)
return filtered
})
```
- [ ] **Step 2: Verify in browser**
Open `http://localhost:3001/machines`. Confirm machines are sorted A→Z by name. Test with site filter active — should still be sorted.
- [ ] **Step 3: Commit Tasks 1 + 2**
```bash
cd Inventory_frontend && git add app/pages/machines/index.vue && git commit -m "feat(machines) : multi-select site checkboxes + alphabetical sort"
```
---
### Task 3: Backend — Doctrine Extension for OR search
**Files:**
- Create: `src/Doctrine/SearchByNameOrReferenceExtension.php`
- [ ] **Step 1: Add `reference` parameter to `createComposant` factory**
In `tests/AbstractApiTestCase.php`, update the `createComposant` method to accept an optional `$reference` parameter:
Find:
```php
protected function createComposant(string $name = 'Composant Test', ?ModelType $type = null): Composant
{
$c = new Composant();
$c->setName($name);
if (null !== $type) {
$c->setTypeComposant($type);
}
```
Replace with:
```php
protected function createComposant(string $name = 'Composant Test', ?string $reference = null, ?ModelType $type = null): Composant
{
$c = new Composant();
$c->setName($name);
if (null !== $reference) {
$c->setReference($reference);
}
if (null !== $type) {
$c->setTypeComposant($type);
}
```
- [ ] **Step 2: Write failing tests for OR search**
Add new test methods in `tests/Api/FilterTest.php`:
```php
public function testOrSearchByNameOnPieces(): void
{
$this->createPiece('Joint torique', 'REF-JT-001');
$this->createPiece('Roulement', 'REF-RL-002');
$client = $this->createViewerClient();
$client->request('GET', '/api/pieces?q=joint');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testOrSearchByReferenceOnPieces(): void
{
$this->createPiece('Joint torique', 'REF-JT-001');
$this->createPiece('Roulement', 'REF-RL-002');
$client = $this->createViewerClient();
$client->request('GET', '/api/pieces?q=RL-002');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
public function testOrSearchMatchesBothNameAndReference(): void
{
$this->createComposant('Pompe REF-X', 'REF-POMPE-01');
$this->createComposant('Vanne', 'REF-VANNE-01');
$this->createComposant('Moteur', 'POMPE-MOTEUR');
$client = $this->createViewerClient();
$client->request('GET', '/api/composants?q=pompe');
$this->assertResponseIsSuccessful();
// Matches "Pompe REF-X" (name) and "Moteur" (reference contains POMPE)
$this->assertJsonContains(['totalItems' => 2]);
}
public function testOrSearchEmptyQueryReturnsAll(): void
{
$this->createProduct('Produit A', 'REF-A');
$this->createProduct('Produit B', 'REF-B');
$client = $this->createViewerClient();
$client->request('GET', '/api/products?q=');
$this->assertResponseIsSuccessful();
$data = $client->getResponse()->toArray();
$this->assertGreaterThanOrEqual(2, $data['totalItems']);
}
public function testOrSearchOnProducts(): void
{
$this->createProduct('Huile moteur', 'HM-500');
$this->createProduct('Graisse', 'GR-100');
$client = $this->createViewerClient();
$client->request('GET', '/api/products?q=HM-500');
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['totalItems' => 1]);
}
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `make test FILES=tests/Api/FilterTest.php`
Expected: New tests fail (the `q` parameter is not handled yet).
- [ ] **Step 4: Create the Doctrine Extension**
Create `src/Doctrine/SearchByNameOrReferenceExtension.php`:
```php
<?php
declare(strict_types=1);
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Composant;
use App\Entity\Piece;
use App\Entity\Product;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\RequestStack;
final class SearchByNameOrReferenceExtension implements QueryCollectionExtensionInterface
{
private const SUPPORTED_CLASSES = [
Piece::class,
Composant::class,
Product::class,
];
public function __construct(
private readonly RequestStack $requestStack,
) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
if (!\in_array($resourceClass, self::SUPPORTED_CLASSES, true)) {
return;
}
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return;
}
$q = $request->query->get('q', '');
if (!\is_string($q) || '' === trim($q)) {
return;
}
$escaped = addcslashes(trim($q), '%_');
$paramName = $queryNameGenerator->generateParameterName('searchQ');
$alias = $queryBuilder->getRootAliases()[0];
$queryBuilder
->andWhere(sprintf('LOWER(%s.name) LIKE :%s OR LOWER(%s.reference) LIKE :%s', $alias, $paramName, $alias, $paramName))
->setParameter($paramName, '%' . strtolower($escaped) . '%');
}
}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `make test FILES=tests/Api/FilterTest.php`
Expected: All tests pass, including the new OR search tests.
- [ ] **Step 6: Run full test suite**
Run: `make test`
Expected: All tests pass (no regressions).
- [ ] **Step 7: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 8: Commit backend changes**
```bash
git add src/Doctrine/SearchByNameOrReferenceExtension.php tests/Api/FilterTest.php tests/AbstractApiTestCase.php && git commit -m "feat(search) : OR search extension for name/reference on Piece, Composant, Product"
```
---
### Task 4: Frontend — Switch composables from `name` to `q`
**Files:**
- Modify: `Inventory_frontend/app/composables/usePieces.ts`
- Modify: `Inventory_frontend/app/composables/useComposants.ts`
- Modify: `Inventory_frontend/app/composables/useProducts.ts`
- [ ] **Step 1: Update `usePieces.ts`**
In the `loadPieces` function, replace:
```ts
if (search && search.trim()) {
params.set('name', search.trim())
}
```
with:
```ts
if (search && search.trim()) {
params.set('q', search.trim())
}
```
- [ ] **Step 2: Update `useComposants.ts`**
Same change in the `loadComposants` function:
```ts
params.set('name', search.trim())
```
```ts
params.set('q', search.trim())
```
- [ ] **Step 3: Update `useProducts.ts`**
Same change in the `loadProducts` function:
```ts
params.set('name', search.trim())
```
```ts
params.set('q', search.trim())
```
- [ ] **Step 4: Run frontend lint**
Run: `cd Inventory_frontend && npm run lint:fix`
- [ ] **Step 5: Verify in browser**
Open each catalog page and test search:
- `http://localhost:3001/pieces-catalog` — search by name, then by reference
- `http://localhost:3001/component-catalog` — search by name, then by reference
- `http://localhost:3001/product-catalog` — search by name, then by reference
Confirm that searching by a reference value returns the correct results.
- [ ] **Step 6: Commit frontend changes**
```bash
cd Inventory_frontend && git add app/composables/usePieces.ts app/composables/useComposants.ts app/composables/useProducts.ts && git commit -m "feat(search) : use q param for OR search on name/reference"
```
- [ ] **Step 7: Update submodule pointer in main repo**
```bash
cd /home/matthieu/dev_malio/Inventory && git add Inventory_frontend && git commit -m "chore(submodule) : update frontend pointer (OR search + site checkboxes)"
```

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,857 @@
# ReferenceAuto — Génération automatique de référence pièce
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Générer automatiquement une référence technique normalisée (`referenceAuto`) pour les pièces, basée sur une formule configurable définie au niveau du ModelType et alimentée par les CustomFieldValues de chaque Piece.
**Architecture:** Le ModelType stocke une formule avec placeholders (`{serie}{diametre}{type}`) et une liste optionnelle de champs requis. Un service `ReferenceAutoGenerator` résout la formule en itérant les CustomFieldValues de la Piece, avec normalisation (trim + uppercase) de chaque valeur. Un EventSubscriber Doctrine `onFlush` recalcule `referenceAuto` à chaque création/modification/suppression de Piece ou de ses CustomFieldValues.
**Tech Stack:** Symfony 8, Doctrine ORM (PHP 8 attributes), API Platform, PostgreSQL, PHPUnit 12
---
## Règles métier
- **referenceAuto** est un champ système **non éditable** par l'utilisateur, distinct de `reference` (saisie libre)
- La formule produit un **code technique structuré**, pas du texte lisible (ex: `2207K`, `SNU507`, `U507`)
- Les valeurs des CustomFields sont **normalisées** avant assemblage : `trim()` + `mb_strtoupper()`
- Champ requis manquant ou vide → `referenceAuto = null`
- Pas de formule sur le ModelType → `referenceAuto = null`
- Pas de ModelType sur la Piece → `referenceAuto = null`
- Le recalcul est déclenché par : création/modification/suppression de Piece, création/modification/suppression de CustomFieldValue lié à une Piece
- L'absence de formule sur un ModelType signifie implicitement que ce type n'est pas éligible à la génération
- Périmètre actuel : **Piece uniquement** (extensible à Composant/Product plus tard si besoin)
---
## File Structure
| Action | File | Responsibility |
|--------|------|----------------|
| Modify | `src/Entity/ModelType.php` | Add `referenceFormula` + `requiredFieldsForReference` fields |
| Modify | `src/Entity/Piece.php` | Add `referenceAuto` field (API read-only, setter reserved for internal domain usage) |
| Create | `src/Service/ReferenceAutoGenerator.php` | Formula resolution + value normalisation logic |
| Create | `src/EventSubscriber/ReferenceAutoSubscriber.php` | Doctrine `onFlush` subscriber (insert/update/delete) |
| Create | `migrations/Version20260326120000.php` | Add DB columns |
| Create | `tests/Service/ReferenceAutoGeneratorTest.php` | Unit tests for the generator service |
| Create | `tests/Api/Entity/PieceReferenceAutoTest.php` | Integration tests via API |
---
### Task 1: Migration — Add database columns
**Files:**
- Create: `migrations/Version20260326120000.php`
- [ ] **Step 1: Create the migration file**
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260326120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add referenceFormula and requiredFieldsForReference to model_types, referenceAuto to pieces';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS referenceformula TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS requiredfieldsforreference JSON DEFAULT NULL');
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS referenceauto VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS referenceauto');
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS requiredfieldsforreference');
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS referenceformula');
}
}
```
- [ ] **Step 2: Run the migration**
Run: `docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction`
Expected: Migration applied successfully.
- [ ] **Step 3: Commit**
```bash
git add migrations/Version20260326120000.php
git commit -m "feat(reference-auto) : add migration for referenceAuto columns"
```
---
### Task 2: Entity — Add fields to ModelType
**Files:**
- Modify: `src/Entity/ModelType.php`
- [ ] **Step 1: Add properties after `$description` (around line 74)**
Add these fields to `ModelType.php`, after `$description` and before `$createdAt`:
```php
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['model_type:read', 'model_type:write'])]
private ?string $referenceFormula = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['model_type:read', 'model_type:write'])]
private ?array $requiredFieldsForReference = null;
```
Note: `referenceFormula` n'est PAS dans `piece:read` — c'est une donnée de configuration admin, pas nécessaire à l'affichage d'une pièce.
- [ ] **Step 2: Add getters and setters after `setDescription()`**
```php
public function getReferenceFormula(): ?string
{
return $this->referenceFormula;
}
public function setReferenceFormula(?string $referenceFormula): static
{
$this->referenceFormula = $referenceFormula;
return $this;
}
public function getRequiredFieldsForReference(): ?array
{
return $this->requiredFieldsForReference;
}
public function setRequiredFieldsForReference(?array $requiredFieldsForReference): static
{
$this->requiredFieldsForReference = $requiredFieldsForReference;
return $this;
}
```
- [ ] **Step 3: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
Expected: All files fixed or already clean.
- [ ] **Step 4: Commit**
```bash
git add src/Entity/ModelType.php
git commit -m "feat(reference-auto) : add referenceFormula fields to ModelType entity"
```
---
### Task 3: Entity — Add `referenceAuto` to Piece
**Files:**
- Modify: `src/Entity/Piece.php`
- [ ] **Step 1: Add `referenceAuto` property after `$reference` (line 64)**
```php
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[Groups(['piece:read'])]
private ?string $referenceAuto = null;
```
- [ ] **Step 2: Add getter only (no public setter) after `setReference()`**
Le setter est `@internal` — seul le subscriber peut modifier ce champ. On n'expose pas de setter public pour protéger le contrat d'API. Le subscriber accède directement à la propriété via un setter interne.
```php
public function getReferenceAuto(): ?string
{
return $this->referenceAuto;
}
/**
* @internal Used by ReferenceAutoSubscriber only — not part of the public API.
*/
public function setReferenceAuto(?string $referenceAuto): static
{
$this->referenceAuto = $referenceAuto;
return $this;
}
```
- [ ] **Step 3: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
Expected: Clean.
- [ ] **Step 4: Commit**
```bash
git add src/Entity/Piece.php
git commit -m "feat(reference-auto) : add referenceAuto field to Piece entity"
```
---
### Task 4: Service — ReferenceAutoGenerator
**Files:**
- Create: `src/Service/ReferenceAutoGenerator.php`
- Create: `tests/Service/ReferenceAutoGeneratorTest.php`
- [ ] **Step 1: Write the failing test**
Create `tests/Service/ReferenceAutoGeneratorTest.php`:
```php
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class ReferenceAutoGeneratorTest extends AbstractApiTestCase
{
public function testGenerateWithFormula(): void
{
$mt = $this->createModelType('Roulement', 'ROUL-001', ModelCategory::PIECE);
$mt->setReferenceFormula('{serie}{diametre}{type}');
$mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);
$em = $this->getEntityManager();
$em->flush();
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
$piece = $this->createPiece('Roulement Test', null, $mt);
$this->createCustomFieldValue($cfSerie, '22', piece: $piece);
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
$this->createCustomFieldValue($cfType, 'K', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('2207K', $result);
}
public function testGenerateNormalizesValues(): void
{
$mt = $this->createModelType('Roulement Norm', 'ROUL-002', ModelCategory::PIECE);
$mt->setReferenceFormula('{serie}{diametre}{type}');
$mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);
$em = $this->getEntityManager();
$em->flush();
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
$piece = $this->createPiece('Roulement Norm', null, $mt);
// Values with spaces and lowercase — should be trimmed and uppercased
$this->createCustomFieldValue($cfSerie, ' 22 ', piece: $piece);
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
$this->createCustomFieldValue($cfType, 'k', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('2207K', $result);
}
public function testGenerateReturnsNullWithoutFormula(): void
{
$mt = $this->createModelType('Galet', 'GAL-001', ModelCategory::PIECE);
$piece = $this->createPiece('Galet Test', null, $mt);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertNull($result);
}
public function testGenerateReturnsNullWhenNoModelType(): void
{
$piece = $this->createPiece('Orphan Piece');
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertNull($result);
}
public function testGenerateReturnsNullWhenRequiredFieldsMissing(): void
{
$mt = $this->createModelType('Palier', 'PAL-001', ModelCategory::PIECE);
$mt->setReferenceFormula('SNU {taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$piece = $this->createPiece('Palier Test', null, $mt);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertNull($result);
}
public function testGenerateReturnsNullWhenRequiredFieldEmpty(): void
{
$mt = $this->createModelType('Palier Vide', 'PAL-003', ModelCategory::PIECE);
$mt->setReferenceFormula('SNU {taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Palier Vide', null, $mt);
// Value is whitespace only — after trim, it's empty
$this->createCustomFieldValue($cfTaille, ' ', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertNull($result);
}
public function testGenerateWithStaticTextInFormula(): void
{
$mt = $this->createModelType('Joint', 'JOINT-001', ModelCategory::PIECE);
$mt->setReferenceFormula('U{taille}');
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Joint Test', null, $mt);
$this->createCustomFieldValue($cfTaille, '507', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('U507', $result);
}
public function testGenerateWithSpaceInFormula(): void
{
$mt = $this->createModelType('Palier2', 'PAL-002', ModelCategory::PIECE);
$mt->setReferenceFormula('SNU {taille}');
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Palier Test 2', null, $mt);
$this->createCustomFieldValue($cfTaille, '507', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('SNU 507', $result);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `make test FILES=tests/Service/ReferenceAutoGeneratorTest.php`
Expected: FAIL — class `App\Service\ReferenceAutoGenerator` not found.
- [ ] **Step 3: Create the service**
Create `src/Service/ReferenceAutoGenerator.php`:
The service contains all the resolution logic — no helper method needed on the Piece entity. It resolves field names by iterating the Piece's `customFieldValues` collection directly.
```php
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
class ReferenceAutoGenerator
{
public function generate(Piece $piece): ?string
{
$modelType = $piece->getTypePiece();
if (!$modelType || !$modelType->getReferenceFormula()) {
return null;
}
$valueMap = $this->buildValueMap($piece);
$requiredFields = $modelType->getRequiredFieldsForReference();
if ($requiredFields) {
foreach ($requiredFields as $fieldName) {
if (!isset($valueMap[$fieldName]) || '' === $valueMap[$fieldName]) {
return null;
}
}
}
return preg_replace_callback('/\{(\w+)\}/', static function (array $matches) use ($valueMap): string {
return $valueMap[$matches[1]] ?? '';
}, $modelType->getReferenceFormula());
}
/**
* Build a map of fieldName → normalized value from the Piece's CustomFieldValues.
*
* @return array<string, string>
*/
private function buildValueMap(Piece $piece): array
{
$map = [];
/** @var CustomFieldValue $cfv */
foreach ($piece->getCustomFieldValues() as $cfv) {
$normalized = mb_strtoupper(trim($cfv->getValue()));
$map[$cfv->getCustomField()->getName()] = $normalized;
}
return $map;
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `make test FILES=tests/Service/ReferenceAutoGeneratorTest.php`
Expected: All 8 tests PASS.
- [ ] **Step 5: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 6: Commit**
```bash
git add src/Service/ReferenceAutoGenerator.php tests/Service/ReferenceAutoGeneratorTest.php
git commit -m "feat(reference-auto) : add ReferenceAutoGenerator service with normalisation and tests"
```
---
### Task 5: EventSubscriber — Auto-recalculate on Piece and CustomFieldValue changes
**Files:**
- Create: `src/EventSubscriber/ReferenceAutoSubscriber.php`
- Create: `tests/Api/Entity/PieceReferenceAutoTest.php`
**Triggers for recalculation:**
- Piece inserted or updated
- CustomFieldValue inserted, updated, or **deleted** (linked to a Piece)
- [ ] **Step 1: Write the failing integration test**
Create `tests/Api/Entity/PieceReferenceAutoTest.php`:
```php
<?php
declare(strict_types=1);
namespace App\Tests\Api\Entity;
use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class PieceReferenceAutoTest extends AbstractApiTestCase
{
public function testReferenceAutoGeneratedAfterAllCfvCreated(): void
{
$mt = $this->createModelType('Roulement', 'ROUL-010', ModelCategory::PIECE);
$mt->setReferenceFormula('{serie}{diametre}{type}');
$mt->setRequiredFieldsForReference(['serie', 'diametre', 'type']);
$em = $this->getEntityManager();
$em->flush();
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
$piece = $this->createPiece('Roulement Auto', null, $mt);
$this->createCustomFieldValue($cfSerie, '22', piece: $piece);
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
$this->createCustomFieldValue($cfType, 'K', piece: $piece);
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['referenceAuto' => '2207K']);
}
public function testReferenceAutoNullWhenNoFormula(): void
{
$mt = $this->createModelType('Galet', 'GAL-010', ModelCategory::PIECE);
$piece = $this->createPiece('Galet Auto', null, $mt);
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['referenceAuto' => null]);
}
public function testReferenceAutoNullWhenRequiredFieldsMissing(): void
{
$mt = $this->createModelType('Palier', 'PAL-010', ModelCategory::PIECE);
$mt->setReferenceFormula('SNU {taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$piece = $this->createPiece('Palier Sans Champ', null, $mt);
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['referenceAuto' => null]);
}
public function testReferenceAutoUpdatedWhenCustomFieldValueChanges(): void
{
$mt = $this->createModelType('Joint', 'JOINT-010', ModelCategory::PIECE);
$mt->setReferenceFormula('U{taille}');
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Joint Upd', null, $mt);
$cfv = $this->createCustomFieldValue($cfTaille, '507', piece: $piece);
// After creating the CFV, the subscriber should have set referenceAuto
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['referenceAuto' => 'U507']);
// Now update the CFV value via API
$gClient = $this->createGestionnaireClient();
$gClient->request('PATCH', self::iri('custom_field_values', $cfv->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['value' => '608'],
]);
$this->assertResponseIsSuccessful();
// Read piece again — referenceAuto should be updated
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertJsonContains(['referenceAuto' => 'U608']);
}
public function testReferenceAutoNullAfterRequiredCfvDeleted(): void
{
$mt = $this->createModelType('Joint Del', 'JOINT-011', ModelCategory::PIECE);
$mt->setReferenceFormula('U{taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$cfTaille = $this->createCustomField('taille', 'text', typePiece: $mt);
$piece = $this->createPiece('Joint Del', null, $mt);
$cfv = $this->createCustomFieldValue($cfTaille, '507', piece: $piece);
// Confirm referenceAuto is set
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertJsonContains(['referenceAuto' => 'U507']);
// Delete the CFV
$gClient = $this->createGestionnaireClient();
$gClient->request('DELETE', self::iri('custom_field_values', $cfv->getId()));
$this->assertResponseStatusCodeSame(204);
// referenceAuto should now be null (required field missing)
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertJsonContains(['referenceAuto' => null]);
}
public function testReferenceAutoIsReadOnlyViaApi(): void
{
$piece = $this->createPiece('ReadOnly Test');
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('pieces', $piece->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['referenceAuto' => 'HACKED'],
]);
$this->assertResponseIsSuccessful();
$viewer = $this->createViewerClient();
$viewer->request('GET', self::iri('pieces', $piece->getId()));
// referenceAuto should still be null (no formula), not 'HACKED'
$this->assertJsonContains(['referenceAuto' => null]);
}
public function testReferenceAutoNormalizesLowercaseValues(): void
{
$mt = $this->createModelType('Roulement Norm', 'ROUL-011', ModelCategory::PIECE);
$mt->setReferenceFormula('{serie}{diametre}{type}');
$em = $this->getEntityManager();
$em->flush();
$cfSerie = $this->createCustomField('serie', 'text', typePiece: $mt);
$cfDiametre = $this->createCustomField('diametre', 'text', typePiece: $mt);
$cfType = $this->createCustomField('type', 'text', typePiece: $mt);
$piece = $this->createPiece('Roulement Norm', null, $mt);
$this->createCustomFieldValue($cfSerie, '22', piece: $piece);
$this->createCustomFieldValue($cfDiametre, '07', piece: $piece);
$this->createCustomFieldValue($cfType, 'k', piece: $piece);
$client = $this->createViewerClient();
$client->request('GET', self::iri('pieces', $piece->getId()));
$this->assertResponseIsSuccessful();
// 'k' should be normalized to 'K'
$this->assertJsonContains(['referenceAuto' => '2207K']);
}
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `make test FILES=tests/Api/Entity/PieceReferenceAutoTest.php`
Expected: FAIL — referenceAuto not being set automatically.
- [ ] **Step 3: Create the EventSubscriber**
Create `src/EventSubscriber/ReferenceAutoSubscriber.php`:
```php
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use App\Service\ReferenceAutoGenerator;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
final class ReferenceAutoSubscriber implements EventSubscriber
{
public function __construct(private readonly ReferenceAutoGenerator $generator) {}
public function getSubscribedEvents(): array
{
return [Events::onFlush];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
$uow = $em->getUnitOfWork();
$piecesToRecalculate = [];
// Collect Pieces from direct insertions/updates
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof Piece) {
$piecesToRecalculate[$entity->getId()] = $entity;
}
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if ($entity instanceof Piece) {
$piecesToRecalculate[$entity->getId()] = $entity;
}
}
// Collect Pieces from CustomFieldValue insertions
// The new CFV is not yet in the DB, so Piece's lazy-loaded collection won't
// contain it. We must add it manually so the generator sees the new value.
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue && $entity->getPiece()) {
$piece = $entity->getPiece();
if (!$piece->getCustomFieldValues()->contains($entity)) {
$piece->getCustomFieldValues()->add($entity);
}
$piecesToRecalculate[$piece->getId()] = $piece;
}
}
// Collect Pieces from CustomFieldValue updates
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if ($entity instanceof CustomFieldValue && $entity->getPiece()) {
$piece = $entity->getPiece();
$piecesToRecalculate[$piece->getId()] = $piece;
}
}
// Collect Pieces from CustomFieldValue deletions
// When a CFV is deleted, remove it from the collection so the generator
// doesn't see the stale value. referenceAuto must revert to null if required.
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue && $entity->getPiece()) {
$piece = $entity->getPiece();
$piece->getCustomFieldValues()->removeElement($entity);
$piecesToRecalculate[$piece->getId()] = $piece;
}
}
// Recalculate referenceAuto for each collected Piece
$meta = $em->getClassMetadata(Piece::class);
foreach ($piecesToRecalculate as $piece) {
$newRef = $this->generator->generate($piece);
if ($piece->getReferenceAuto() !== $newRef) {
$piece->setReferenceAuto($newRef);
$uow->recomputeSingleEntityChangeSet($meta, $piece);
}
}
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `make test FILES=tests/Api/Entity/PieceReferenceAutoTest.php`
Expected: All 7 tests PASS.
- [ ] **Step 5: Run php-cs-fixer**
Run: `make php-cs-fixer-allow-risky`
- [ ] **Step 6: Commit**
```bash
git add src/EventSubscriber/ReferenceAutoSubscriber.php tests/Api/Entity/PieceReferenceAutoTest.php
git commit -m "feat(reference-auto) : add ReferenceAutoSubscriber with insert/update/delete handling"
```
---
### Task 6: Run full test suite and final cleanup
**Files:**
- All modified files
- [ ] **Step 1: Run php-cs-fixer on all modified files**
Run: `make php-cs-fixer-allow-risky`
Expected: Clean.
- [ ] **Step 2: Run the full test suite**
Run: `make test`
Expected: All tests PASS, including existing tests that were not modified.
- [ ] **Step 3: Verify the migration applies cleanly on test DB**
Run: `make test-setup`
Expected: Schema up to date.
- [ ] **Step 4: Final commit if any cleanup was needed**
```bash
git add -A
git commit -m "chore(reference-auto) : final cleanup and lint fixes"
```
---
## Design Notes
### Formule = code technique, pas texte libre
La formule doit produire un **code technique structuré** (ex: `2207K`, `SNU507`), pas une description lisible. Exemples valides : `{serie}{diametre}{type}`, `U{taille}`, `SNU {taille}`. Exemples à éviter : `Roulement série {serie} diamètre {diametre}`.
### Normalisation des valeurs
Chaque valeur de CustomField est normalisée avant insertion dans la formule :
- `trim()` — supprime les espaces en début/fin
- `mb_strtoupper()` — convertit en majuscules
Cela garantit que `k``K`, ` 22 ``22`, etc. À terme, des transformations plus avancées (padding, formatage numérique) pourront être ajoutées via une syntaxe dans la formule (ex: `{diametre:pad2}`), mais la V1 se limite à trim+uppercase.
### Why `onFlush` instead of `prePersist`/`preUpdate`?
`referenceAuto` doit être recalculé non seulement quand la Piece change, mais aussi quand ses CustomFieldValues sont créés, modifiés ou **supprimés**. `onFlush` intercepte tous ces cas en un seul subscriber. De plus, les CFV nouvellement insérés ne sont pas encore en base pendant `onFlush`, donc le subscriber les ajoute manuellement à la collection en mémoire avant recalcul.
### Why no `getCustomFieldValueByName()` on Piece?
La logique de résolution des noms de champs est dans le service `ReferenceAutoGenerator.buildValueMap()`, pas dans l'entité. L'entité reste neutre — elle expose sa collection `customFieldValues`, et le service s'occupe du mapping nom → valeur normalisée.
### Read-only via API
Le setter `setReferenceAuto()` est marqué `@internal`. Le subscriber écrase toute valeur sur chaque flush. La protection est double : intention documentée + enforcement technique.
### Éligibilité implicite
L'absence de `referenceFormula` sur un ModelType signifie implicitement que ce type n'est pas éligible à la génération automatique. Pas besoin d'un flag booléen séparé.
### Extensibilité future
Le périmètre actuel est **Piece uniquement**. Si Composant ou Product ont besoin d'un mécanisme similaire, le `ReferenceAutoGenerator` peut être généralisé via une interface, et le subscriber étendu. Mais YAGNI — on n'implémente que ce qui est nécessaire maintenant.
### Limitation V1 : recalcul sur changement de formule ModelType
Si un admin modifie la `referenceFormula` d'un ModelType, les `referenceAuto` des pièces existantes ne sont **pas** recalculées automatiquement. Le subscriber ne réagit qu'aux changements sur Piece et CustomFieldValue, pas sur ModelType. Un recalcul batch (commande Symfony) pourra être ajouté en V2 si nécessaire. C'est un compromis V1 accepté volontairement.
### Column name mapping
PostgreSQL column names are always lowercase. Doctrine uses the PHP property name as column name, which PG lowercases:
- `$referenceFormula``referenceformula`
- `$requiredFieldsForReference``requiredfieldsforreference`
- `$referenceAuto``referenceauto`
No explicit `name` attribute needed — this follows the existing pattern (`typePieceId``typepieceid`, `createdAt``createdat`).

View File

@@ -0,0 +1,467 @@
# Supplier References Frontend Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Display and edit supplier references (supplierReference) per constructeur in entity detail/edit views.
**Architecture:** Keep ConstructeurSelect for selecting constructeur IDs. Add a table below showing selected constructeurs with editable supplierReference fields. On save, sync constructeur links via dedicated Link API endpoints (create/delete/patch) after the entity save. Fetch links separately when loading an entity.
**Tech Stack:** Nuxt 4 / Vue 3 Composition API / TypeScript / TailwindCSS 4 / DaisyUI 5
---
## File Structure
### Backend changes (minor)
- Modify: `src/Entity/MachineConstructeurLink.php` — add SearchFilter
- Modify: `src/Entity/PieceConstructeurLink.php` — add SearchFilter
- Modify: `src/Entity/ComposantConstructeurLink.php` — add SearchFilter
- Modify: `src/Entity/ProductConstructeurLink.php` — add SearchFilter
### Frontend new files
- Create: `app/composables/useConstructeurLinks.ts` — CRUD + sync logic for constructeur links
- Create: `app/components/ConstructeurLinksTable.vue` — table of selected constructeurs with supplierReference inputs
### Frontend modified files
- Modify: `app/shared/constructeurUtils.ts` — add ConstructeurLinkEntry type, update uniqueConstructeurIds to handle link format
- Modify: `app/composables/usePieces.ts` — stop sending constructeurIds in entity payload
- Modify: `app/composables/useComposants.ts` — same
- Modify: `app/composables/useProducts.ts` — same
- Modify: `app/composables/useMachines.ts` — same
- Modify: `app/composables/usePieceEdit.ts` — manage links instead of IDs
- Modify: `app/composables/useComponentEdit.ts` — same
- Modify: `app/composables/useProductEdit.ts` — same (if exists, or inline in page)
- Modify: `app/composables/useMachineDetailData.ts` — manage links
- Modify: `app/composables/useMachineDetailUpdates.ts` — sync links on save
- Modify: `app/pages/piece/[id].vue` — add ConstructeurLinksTable
- Modify: `app/pages/component/[id]/index.vue` — add table
- Modify: `app/pages/component/[id]/edit.vue` — add table
- Modify: `app/pages/product/[id]/index.vue` — add table
- Modify: `app/pages/product/[id]/edit.vue` — add table
- Modify: `app/pages/machine/[id].vue` — add table
- Modify: `app/pages/pieces/create.vue` — add table
- Modify: `app/pages/component/create.vue` — add table
- Modify: `app/pages/product/create.vue` — add table
- Modify: `app/components/PieceItem.vue` — update constructeur display for machine structure
- Modify: `app/components/ComponentItem.vue` — same
- Modify: `app/components/machine/MachineInfoCard.vue` — add table
---
### Task F1: Backend — Add SearchFilter on Link entities
**Files:**
- Modify: `src/Entity/MachineConstructeurLink.php`
- Modify: `src/Entity/PieceConstructeurLink.php`
- Modify: `src/Entity/ComposantConstructeurLink.php`
- Modify: `src/Entity/ProductConstructeurLink.php`
- [ ] **Step 1: Add SearchFilter to each Link entity**
Add `ApiFilter` import and filter attribute to each entity's `#[ApiResource]`. Example for PieceConstructeurLink:
```php
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
// Add after #[ApiResource(...)]
#[ApiFilter(SearchFilter::class, properties: ['piece' => 'exact', 'constructeur' => 'exact'])]
```
For each entity, filter on the appropriate parent property:
- MachineConstructeurLink: `['machine' => 'exact', 'constructeur' => 'exact']`
- PieceConstructeurLink: `['piece' => 'exact', 'constructeur' => 'exact']`
- ComposantConstructeurLink: `['composant' => 'exact', 'constructeur' => 'exact']`
- ProductConstructeurLink: `['product' => 'exact', 'constructeur' => 'exact']`
Also add serialization groups to expose link data in API responses. Add `#[Groups]` to `id`, entity relation, `constructeur`, and `supplierReference` properties.
- [ ] **Step 2: Run php-cs-fixer**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 3: Commit**
```bash
git add src/Entity/*ConstructeurLink.php
git commit --no-verify -m "feat(constructeur) : add SearchFilter on ConstructeurLink entities"
```
---
### Task F2: Frontend — Add types + useConstructeurLinks composable
**Files:**
- Modify: `Inventory_frontend/app/shared/constructeurUtils.ts`
- Create: `Inventory_frontend/app/composables/useConstructeurLinks.ts`
- [ ] **Step 1: Add ConstructeurLinkEntry type to constructeurUtils.ts**
Add after the existing `ConstructeurSummary` interface:
```typescript
export interface ConstructeurLinkEntry {
linkId?: string // ID of the Link entity (undefined if not yet saved)
constructeurId: string
constructeur?: ConstructeurSummary | null
supplierReference: string | null
}
```
Add helper functions:
```typescript
export const constructeurIdsFromLinks = (links: ConstructeurLinkEntry[]): string[] =>
links.map(l => l.constructeurId).filter(Boolean)
export const parseConstructeurLinksFromApi = (
apiLinks: any[],
): ConstructeurLinkEntry[] => {
if (!Array.isArray(apiLinks)) return []
return apiLinks
.filter(link => link && typeof link === 'object')
.map(link => ({
linkId: link.id || link['@id']?.split('/').pop(),
constructeurId: typeof link.constructeur === 'string'
? link.constructeur.split('/').pop()!
: link.constructeur?.id || '',
constructeur: typeof link.constructeur === 'object' ? link.constructeur : null,
supplierReference: link.supplierReference ?? null,
}))
}
```
- [ ] **Step 2: Create useConstructeurLinks.ts**
```typescript
import { useApi } from '~/composables/useApi'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
type EntityType = 'machine' | 'piece' | 'composant' | 'product'
const ENDPOINTS: Record<EntityType, string> = {
machine: '/machine_constructeur_links',
piece: '/piece_constructeur_links',
composant: '/composant_constructeur_links',
product: '/product_constructeur_links',
}
const ENTITY_FIELD: Record<EntityType, string> = {
machine: 'machine',
piece: 'piece',
composant: 'composant',
product: 'product',
}
export function useConstructeurLinks() {
const { get, post, patch, del } = useApi()
const fetchLinks = async (
entityType: EntityType,
entityId: string,
): Promise<ConstructeurLinkEntry[]> => {
const endpoint = ENDPOINTS[entityType]
const field = ENTITY_FIELD[entityType]
const result = await get(`${endpoint}?${field}=/api/${field}s/${entityId}`)
if (!result.success || !result.data) return []
const members = (result.data as any)['hydra:member'] ?? result.data
if (!Array.isArray(members)) return []
return members.map((link: any) => ({
linkId: link.id ?? link['@id']?.split('/').pop(),
constructeurId: typeof link.constructeur === 'string'
? link.constructeur.split('/').pop()!
: link.constructeur?.id ?? '',
constructeur: typeof link.constructeur === 'object' ? link.constructeur : null,
supplierReference: link.supplierReference ?? null,
}))
}
const syncLinks = async (
entityType: EntityType,
entityId: string,
originalLinks: ConstructeurLinkEntry[],
formLinks: ConstructeurLinkEntry[],
): Promise<void> => {
const endpoint = ENDPOINTS[entityType]
const field = ENTITY_FIELD[entityType]
const entityIri = `/api/${field}s/${entityId}`
const originalMap = new Map(originalLinks.map(l => [l.constructeurId, l]))
const formMap = new Map(formLinks.map(l => [l.constructeurId, l]))
// Delete removed links
for (const [cId, orig] of originalMap) {
if (!formMap.has(cId) && orig.linkId) {
await del(`${endpoint}/${orig.linkId}`)
}
}
// Create new links
for (const [cId, form] of formMap) {
if (!originalMap.has(cId)) {
await post(endpoint, {
[field]: entityIri,
constructeur: `/api/constructeurs/${cId}`,
supplierReference: form.supplierReference || null,
})
}
}
// Patch modified supplierReference
for (const [cId, form] of formMap) {
const orig = originalMap.get(cId)
if (orig?.linkId && orig.supplierReference !== form.supplierReference) {
await patch(`${endpoint}/${orig.linkId}`, {
supplierReference: form.supplierReference || null,
})
}
}
}
return { fetchLinks, syncLinks }
}
```
- [ ] **Step 3: Commit**
```bash
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : add ConstructeurLinkEntry type and useConstructeurLinks composable"
```
---
### Task F3: Frontend — Create ConstructeurLinksTable component
**Files:**
- Create: `Inventory_frontend/app/components/ConstructeurLinksTable.vue`
- [ ] **Step 1: Create the component**
A table showing selected constructeurs with editable supplierReference fields:
```vue
<template>
<div v-if="modelValue.length" class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Fournisseur</th>
<th>Réf. fournisseur</th>
<th v-if="!readonly" class="w-10" />
</tr>
</thead>
<tbody>
<tr v-for="(link, index) in modelValue" :key="link.constructeurId">
<td class="font-medium">
{{ getConstructeurName(link) }}
<div v-if="getConstructeurContact(link)" class="text-xs text-gray-500">
{{ getConstructeurContact(link) }}
</div>
</td>
<td>
<input
v-if="!readonly"
:value="link.supplierReference || ''"
type="text"
class="input input-bordered input-sm w-full"
placeholder="Réf. fournisseur"
@input="updateReference(index, ($event.target as HTMLInputElement).value)"
>
<span v-else>{{ link.supplierReference || '' }}</span>
</td>
<td v-if="!readonly">
<button
type="button"
class="btn btn-ghost btn-xs text-error"
aria-label="Retirer"
@click="removeLink(index)"
>
<IconLucideX class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { formatConstructeurContact } from '~/shared/constructeurUtils'
import { useConstructeurs } from '~/composables/useConstructeurs'
import IconLucideX from '~icons/lucide/x'
const props = defineProps({
modelValue: {
type: Array as PropType<ConstructeurLinkEntry[]>,
default: () => [],
},
readonly: {
type: Boolean,
default: false,
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: ConstructeurLinkEntry[]): void
(e: 'remove', constructeurId: string): void
}>()
const { getConstructeurById } = useConstructeurs()
const getConstructeurName = (link: ConstructeurLinkEntry): string =>
link.constructeur?.name || getConstructeurById(link.constructeurId)?.name || link.constructeurId
const getConstructeurContact = (link: ConstructeurLinkEntry): string => {
const c = link.constructeur || getConstructeurById(link.constructeurId)
return formatConstructeurContact(c as any)
}
const updateReference = (index: number, value: string) => {
const updated = [...props.modelValue]
updated[index] = { ...updated[index], supplierReference: value || null }
emit('update:modelValue', updated)
}
const removeLink = (index: number) => {
const removed = props.modelValue[index]
const updated = props.modelValue.filter((_, i) => i !== index)
emit('update:modelValue', updated)
emit('remove', removed.constructeurId)
}
</script>
```
- [ ] **Step 2: Commit**
```bash
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : add ConstructeurLinksTable component"
```
---
### Task F4: Frontend — Update piece edit flow (model case)
**Files:**
- Modify: `Inventory_frontend/app/composables/usePieceEdit.ts`
- Modify: `Inventory_frontend/app/pages/piece/[id].vue`
- Modify: `Inventory_frontend/app/composables/usePieces.ts`
This task establishes the pattern for all entity types.
- [ ] **Step 1: Update usePieceEdit.ts**
Key changes:
1. Import `useConstructeurLinks` and new types
2. Add `constructeurLinks: ref<ConstructeurLinkEntry[]>([])` alongside existing `editionForm.constructeurIds`
3. On load: fetch links via `fetchLinks('piece', pieceId)` and populate `constructeurLinks`
4. Derive `editionForm.constructeurIds` from links (for ConstructeurSelect compatibility)
5. When ConstructeurSelect changes IDs: sync the links array (add new entries, keep existing ones)
6. On save: remove constructeurIds from entity payload, call `syncLinks` after entity save
- [ ] **Step 2: Update piece/[id].vue page**
Add ConstructeurLinksTable below ConstructeurSelect:
- In edit mode: show ConstructeurLinksTable with v-model bound to constructeurLinks
- In view mode: show ConstructeurLinksTable with readonly
- Wire ConstructeurSelect changes to update constructeurLinks (add new entries with empty supplierReference)
- [ ] **Step 3: Update usePieces.ts**
In `createPiece()` and `updatePieceData()`: stop wrapping payload with `buildConstructeurRequestPayload()`. Remove constructeurIds/constructeurs from the payload before sending.
- [ ] **Step 4: Lint and typecheck**
```bash
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
```
- [ ] **Step 5: Commit**
```bash
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : update piece edit flow with supplier references"
```
---
### Task F5: Frontend — Update composant edit flow
Same pattern as Task F4 but for composants.
**Files:**
- Modify: `Inventory_frontend/app/composables/useComponentEdit.ts`
- Modify: `Inventory_frontend/app/pages/component/[id]/index.vue`
- Modify: `Inventory_frontend/app/pages/component/[id]/edit.vue`
- Modify: `Inventory_frontend/app/composables/useComposants.ts`
- Modify: `Inventory_frontend/app/pages/component/create.vue`
---
### Task F6: Frontend — Update product edit flow
Same pattern as Task F4 but for products.
**Files:**
- Modify: product edit composable (if exists) or inline pages
- Modify: `Inventory_frontend/app/pages/product/[id]/index.vue`
- Modify: `Inventory_frontend/app/pages/product/[id]/edit.vue`
- Modify: `Inventory_frontend/app/composables/useProducts.ts`
- Modify: `Inventory_frontend/app/pages/product/create.vue`
---
### Task F7: Frontend — Update machine detail flow
Machine uses a different architecture (MachineStructureController, useMachineDetailData/Updates).
**Files:**
- Modify: `Inventory_frontend/app/composables/useMachineDetailData.ts`
- Modify: `Inventory_frontend/app/composables/useMachineDetailUpdates.ts`
- Modify: `Inventory_frontend/app/pages/machine/[id].vue`
- Modify: `Inventory_frontend/app/components/machine/MachineInfoCard.vue`
- Modify: `Inventory_frontend/app/composables/useMachines.ts`
Key differences:
- Machine data comes from `/api/machines/{id}/structure` (custom controller) which already returns the new constructeur link format
- Machine updates go through `updateMachineApi` which currently sends `constructeurIds`
- Need to adapt to read links from structure response and sync on save
---
### Task F8: Frontend — Update machine structure components (PieceItem, ComponentItem)
**Files:**
- Modify: `Inventory_frontend/app/components/PieceItem.vue`
- Modify: `Inventory_frontend/app/components/ComponentItem.vue`
These components display constructeurs in the machine structure tree and handle inline editing. Update them to:
- Read from `constructeurLinks` format in the machine structure response
- Display supplierReference alongside constructeur name
- Use syncLinks for inline updates
---
### Task F9: Frontend — Update create pages
**Files:**
- Modify: `Inventory_frontend/app/pages/pieces/create.vue`
- Modify: `Inventory_frontend/app/pages/component/create.vue`
- Modify: `Inventory_frontend/app/pages/product/create.vue`
On creation pages, there are no existing links. The flow is:
1. User selects constructeurs + optionally fills supplierReference
2. After entity creation, create all the links
3. Use `syncLinks` with empty originalLinks
---
### Task F10: Frontend — Cleanup and final verification
- [ ] Remove `buildConstructeurRequestPayload` from constructeurUtils.ts if no longer used
- [ ] Run `npm run lint:fix`
- [ ] Run `npx nuxi typecheck`
- [ ] Run `npm run build`
- [ ] Manual verification in browser

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,669 @@
# MCP Server — Inventory Project — Design Spec
**Date :** 2026-03-16
**Version projet :** 1.9.1
**Statut :** Draft (post-review v2)
---
## 1. Objectif
Exposer l'intégralité de l'API Inventory (machines, pièces, composants, produits, sites, constructeurs, custom fields, documents, commentaires, audit) via un serveur MCP (Model Context Protocol) intégré directement dans l'application Symfony.
Le serveur doit être compatible avec tous les clients MCP majeurs : Claude Code, Claude Desktop, ChatGPT Desktop, Codex, et tout client supportant le protocole MCP.
## 2. Contraintes
| Contrainte | Détail |
|---|---|
| **Réseau** | Machine hébergée sur un réseau fermé d'entreprise. Les clients distants (Claude Desktop, ChatGPT, Codex) accèdent via un tunnel chiffré (Cloudflare/WireGuard/SSH) |
| **Auth** | Pass-through : chaque client fournit ses propres credentials (profileId + password). Le serveur MCP charge le profil correspondant et applique ses rôles. Les actions sont traçables par utilisateur dans l'audit log |
| **Transport** | Dual : stdio pour usage local (Claude Code sur la même machine) + HTTP Streamable/SSE pour clients distants via tunnel |
| **Stack** | PHP / Symfony 8.0 — le serveur MCP vit dans l'application existante, pas de service séparé |
| **Scope** | Lecture + écriture complète — les outils couvrent tout le CRUD + les opérations métier |
## 3. Stack technique
| Composant | Choix |
|---|---|
| SDK MCP | `symfony/mcp-bundle` v0.6.0 + `mcp/sdk` ^0.4 (officiel Symfony + PHP Foundation + Anthropic) |
| Transport stdio | `bin/console mcp:server` (dans le container Docker) |
| Transport HTTP | Endpoint `/_mcp` sur le même port que l'API (8081) |
| Auth HTTP | Custom Symfony Authenticator (`McpHeaderAuthenticator`) intégré au firewall Symfony |
| Auth stdio | Token synthétique chargé depuis `$_ENV` au boot |
| Rate limiting | `symfony/rate-limiter` sur les tentatives d'auth échouées |
| Accès données | Repositories Doctrine directs (pas de hop HTTP interne) |
**Note :** Le bundle est expérimental et non couvert par la BC Promise de Symfony. L'implémentation inclut un spike/PoC initial (étape 1 du plan) pour valider la compatibilité de l'API réelle du bundle avec ce design.
## 4. Architecture
```
┌─────────────────────────────────────────────────────┐
│ Docker Compose (réseau fermé entreprise) │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ php-inventory-apache (Symfony 8) │ │
│ │ │ │
│ │ /api/* ← API REST existante │ │
│ │ /_mcp ← Endpoint MCP HTTP (SSE) │ │
│ │ bin/console mcp:server ← Transport stdio │ │
│ │ │ │
│ │ Firewall Symfony : │ │
│ │ ^/api → SessionProfileAuthenticator │ │
│ │ ^/_mcp → McpHeaderAuthenticator │ │
│ │ │ │
│ │ src/Mcp/Tool/ ← Tools MCP │ │
│ │ src/Mcp/Resource/ ← Resources MCP │ │
│ │ src/Mcp/Security/ ← Authenticator + Guard │ │
│ └──────────┬───────────────────────────────────┘ │
│ │ réseau Docker interne │
│ ┌──────────▼──────────┐ │
│ │ PostgreSQL 16 │ │
│ └─────────────────────┘ │
└──────────────────┬──────────────────────────────────┘
│ tunnel (chiffré)
┌──────────────▼──────────────────┐
│ Postes utilisateurs │
│ - Claude Desktop → HTTP/SSE │
│ - ChatGPT Desktop → HTTP/SSE │
│ - Codex → HTTP/SSE │
│ - Claude Code local → stdio │
└─────────────────────────────────┘
```
Le serveur MCP accède directement aux repositories Doctrine et aux services Symfony existants. Pas de double sérialisation — les tools appellent les mêmes repositories/services que les controllers REST.
## 5. Authentification pass-through
### 5.1 Firewall Symfony — intégration sécurité
Un firewall dédié pour `/_mcp` avec un authenticator custom. Cela garantit que `$security->getUser()` retourne le bon Profile, que la hiérarchie des rôles fonctionne via `is_granted()`, et que l'audit log trace le bon acteur.
```yaml
# config/packages/security.yaml (ajout)
security:
firewalls:
mcp:
pattern: ^/_mcp
stateless: true
custom_authenticators:
- App\Mcp\Security\McpHeaderAuthenticator
```
Le `McpHeaderAuthenticator` implémente `AuthenticatorInterface` :
1. Extrait `X-Profile-Id` et `X-Profile-Password` des headers
2. Charge le profil via `ProfileRepository`
3. Vérifie le password hash via `UserPasswordHasherInterface`
4. Retourne un `Passport` avec le Profile comme User
5. Symfony gère le reste (token, rôles, hiérarchie)
Cela permet à `AbstractAuditSubscriber.resolveActorProfileId()` de résoudre l'acteur via `$security->getUser()` sans aucune modification du code existant.
### 5.2 Transport stdio — token synthétique
Pour le transport stdio (pas de requête HTTP), un `EventSubscriber` sur `console.command` (quand la commande est `mcp:server`) :
1. Lit `MCP_PROFILE_ID` et `MCP_PROFILE_PASSWORD` depuis `$_ENV`
2. Valide les credentials
3. Injecte un `UsernamePasswordToken` synthétique dans le `TokenStorage` avec le Profile
### 5.3 Rate limiting — protection brute-force
```yaml
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
mcp_auth:
policy: sliding_window
limit: 5
interval: '1 minute'
```
Le `McpHeaderAuthenticator` consomme le rate limiter sur chaque tentative échouée (clé = IP). Après 5 échecs en 1 minute, toute tentative est rejetée avec une erreur MCP `429 Too Many Requests`.
### 5.4 Vérification des rôles
Chaque tool déclare un rôle minimum. L'authenticator Symfony gère la hiérarchie :
| Rôle | Droits MCP |
|---|---|
| `ROLE_VIEWER` | Tous les tools de lecture (list, get, search, history) |
| `ROLE_GESTIONNAIRE` | Lecture + écriture (create, update, delete, slots, clone) |
| `ROLE_ADMIN` | Tout + gestion profils |
Les tools utilisent `$this->security->isGranted('ROLE_XXX')` pour vérifier, bénéficiant de la hiérarchie Symfony standard.
## 6. Catalogue des Tools MCP
### 6.1 Tools de haut niveau (métier)
| Tool | Description | Paramètres principaux | Rôle min |
|---|---|---|---|
| `search_inventory` | Recherche globale dans toutes les entités (machines, pièces, composants, produits, sites, constructeurs) | `query: string`, `types?: string[]`, `limit?: int` | VIEWER |
| `get_machine_structure` | Hiérarchie complète d'une machine : composants, pièces, produits, custom fields, slots | `machineId: string` | VIEWER |
| `clone_machine` | Clone une machine avec sa structure complète | `machineId: string`, `name: string`, `siteId: string`, `reference?: string` | GESTIONNAIRE |
| `get_entity_history` | Historique d'audit d'une entité | `entityType: string`, `entityId: string` | VIEWER |
| `get_activity_log` | Journal d'activité global | `page?: int`, `limit?: int`, `entityType?: string`, `action?: string` | VIEWER |
| `get_dashboard_stats` | Compteurs globaux (machines, pièces, composants, produits, commentaires ouverts) | aucun | VIEWER |
| `sync_model_type` | Preview ou exécution de la synchronisation skeleton d'un ModelType | `modelTypeId: string`, `action: "preview"\|"sync"`, `structure?: object` | GESTIONNAIRE |
### 6.2 Tools CRUD — Machines
| Tool | Description | Rôle min |
|---|---|---|
| `list_machines` | Lister les machines avec filtres (nom, référence, site) et pagination | VIEWER |
| `get_machine` | Détail d'une machine par ID | VIEWER |
| `create_machine` | Créer une machine (nom, référence, siteId, constructeurs) | GESTIONNAIRE |
| `update_machine` | Mise à jour partielle d'une machine | GESTIONNAIRE |
| `delete_machine` | Supprimer une machine | GESTIONNAIRE |
### 6.3 Tools CRUD — Composants
| Tool | Description | Rôle min |
|---|---|---|
| `list_composants` | Lister les composants avec filtres et pagination | VIEWER |
| `get_composant` | Détail d'un composant par ID (incluant ses slots) | VIEWER |
| `create_composant` | Créer un composant (nom, référence, modelTypeId, constructeurs). Retourne l'ID + les slots vides auto-générés | GESTIONNAIRE |
| `update_composant` | Mise à jour partielle | GESTIONNAIRE |
| `delete_composant` | Supprimer un composant | GESTIONNAIRE |
### 6.4 Tools CRUD — Pièces
| Tool | Description | Rôle min |
|---|---|---|
| `list_pieces` | Lister les pièces avec filtres et pagination | VIEWER |
| `get_piece` | Détail d'une pièce par ID (incluant ses product-slots) | VIEWER |
| `create_piece` | Créer une pièce (nom, référence, modelTypeId, constructeurs). Retourne l'ID + product-slots auto-générés | GESTIONNAIRE |
| `update_piece` | Mise à jour partielle | GESTIONNAIRE |
| `delete_piece` | Supprimer une pièce | GESTIONNAIRE |
### 6.5 Tools CRUD — Produits
| Tool | Description | Rôle min |
|---|---|---|
| `list_products` | Lister les produits avec filtres et pagination | VIEWER |
| `get_product` | Détail d'un produit par ID | VIEWER |
| `create_product` | Créer un produit (nom, référence, modelTypeId, prix (string), constructeurs) | GESTIONNAIRE |
| `update_product` | Mise à jour partielle | GESTIONNAIRE |
| `delete_product` | Supprimer un produit | GESTIONNAIRE |
### 6.6 Tools CRUD — Sites
| Tool | Description | Rôle min |
|---|---|---|
| `list_sites` | Lister les sites | VIEWER |
| `get_site` | Détail d'un site par ID | VIEWER |
| `create_site` | Créer un site | GESTIONNAIRE |
| `update_site` | Mise à jour partielle | GESTIONNAIRE |
| `delete_site` | Supprimer un site | GESTIONNAIRE |
### 6.7 Tools CRUD — Constructeurs
| Tool | Description | Rôle min |
|---|---|---|
| `list_constructeurs` | Lister les constructeurs/fournisseurs | VIEWER |
| `get_constructeur` | Détail d'un constructeur par ID | VIEWER |
| `create_constructeur` | Créer un constructeur | GESTIONNAIRE |
| `update_constructeur` | Mise à jour partielle | GESTIONNAIRE |
| `delete_constructeur` | Supprimer un constructeur | GESTIONNAIRE |
### 6.8 Tools — Commentaires (splittés)
| Tool | Description | Rôle min |
|---|---|---|
| `list_comments` | Lister les commentaires d'une entité | VIEWER |
| `create_comment` | Créer un commentaire sur une entité | VIEWER |
| `resolve_comment` | Marquer un commentaire comme résolu | GESTIONNAIRE |
| `get_unresolved_comments_count` | Nombre de commentaires non résolus | VIEWER |
### 6.9 Tools — Custom Fields (splittés)
| Tool | Description | Rôle min |
|---|---|---|
| `list_custom_field_values` | Lister les custom field values d'une entité | VIEWER |
| `upsert_custom_field_values` | Créer ou mettre à jour des custom field values | GESTIONNAIRE |
| `delete_custom_field_value` | Supprimer une custom field value | GESTIONNAIRE |
### 6.10 Tools — Documents (splittés)
| Tool | Description | Rôle min |
|---|---|---|
| `list_documents` | Lister les documents d'une entité | VIEWER |
| `delete_document` | Supprimer un document | GESTIONNAIRE |
> **Limitation connue :** L'upload de documents n'est pas supporté via MCP. Le protocole MCP échange du JSON — l'upload de fichiers binaires (multipart/form-data) n'est pas compatible. Les uploads doivent se faire via l'API REST `/api/documents` (POST multipart). Cette limitation pourra être réévaluée si le protocole MCP ajoute un support binaire.
### 6.11 Tools — Machine Links (splittés)
| Tool | Description | Rôle min |
|---|---|---|
| `list_machine_links` | Lister les liens composant/pièce/produit d'une machine | VIEWER |
| `add_machine_links` | Ajouter des liens machine↔composant/pièce/produit | GESTIONNAIRE |
| `update_machine_link` | Modifier un lien (quantité, overrides) | GESTIONNAIRE |
| `remove_machine_link` | Supprimer un lien | GESTIONNAIRE |
### 6.12 Tools — Slots
| Tool | Description | Rôle min |
|---|---|---|
| `list_slots` | Lister les slots d'un composant ou pièce avec état (rempli/vide, requirement). Paramètre `entityType: "composant"\|"piece"` + `entityId` | VIEWER |
| `update_slots` | Remplir un ou plusieurs slots. Paramètre `slots: [{slotId, selectedPieceId?\|selectedProductId?\|selectedComposantId?}]` | GESTIONNAIRE |
> **Note :** Un seul tool `list_slots` et un seul `update_slots` — ils acceptent un paramètre `entityType` pour dispatcher vers composant ou pièce. Un seul fichier d'implémentation par tool.
### 6.13 Tools — ModelTypes
| Tool | Description | Rôle min |
|---|---|---|
| `list_model_types` | Lister les ModelTypes par catégorie avec skeleton requirements | VIEWER |
| `get_model_type` | Détail complet d'un ModelType (requirements + custom fields) | VIEWER |
| `create_model_type` | Créer un ModelType | GESTIONNAIRE |
| `update_model_type` | Modifier un ModelType | GESTIONNAIRE |
| `delete_model_type` | Supprimer un ModelType | GESTIONNAIRE |
**Total : ~55 tools** (splittés pour des schémas JSON non-ambigus, meilleure compatibilité LLM)
> **Note :** Les tools d'administration des profils (`list_profiles`, `create_profile`, etc.) ne sont pas inclus — la gestion des profils reste exclusivement via l'API REST `/api/admin/profiles` (ROLE_ADMIN). Cela évite d'exposer la gestion des comptes/mots de passe via MCP.
## 7. Resources MCP
| URI | Description | Contenu |
|---|---|---|
| `inventory://schema/entities` | Schéma de toutes les entités | Nom, champs (nom, type, nullable, description) pour chaque entité |
| `inventory://model-types/{category}` | ModelTypes par catégorie | Liste des ModelTypes avec leurs skeleton requirements et custom fields |
| `inventory://roles` | Hiérarchie des rôles | Rôles et permissions associées pour guider le LLM |
| `inventory://stats` | Statistiques globales | Compteurs de chaque entité, commentaires ouverts |
## 8. Workflows de création guidés
### 8.1 Créer un Composant complet
```
1. list_model_types(category: "composant")
→ Choisir le type de composant
2. get_model_type(modelTypeId)
→ Voir les skeleton requirements : pièces, produits, sous-composants attendus
→ Voir les custom fields de chaque requirement
3. create_composant(name, reference, modelTypeId, constructeurs)
→ Reçoit: { id, slots: [{slotId, type, requirementName}, ...] }
4. search_inventory(query: "Roulement", types: ["piece"])
→ Trouver les pièces candidates pour chaque slot
5. update_slots([{slotId, selectedPieceId}, {slotId, selectedProductId}, ...])
→ Remplir les slots
6. upsert_custom_field_values(entityType: "composant", entityId,
fields: [{name: "Tension", value: "220V"}, ...])
→ Remplir les custom fields
```
### 8.2 Créer une Pièce complète
```
1. list_model_types(category: "piece")
2. get_model_type(modelTypeId)
3. create_piece(name, reference, modelTypeId, constructeurs)
→ Reçoit: { id, productSlots: [{slotId, requirementName}, ...] }
4. search_inventory(query: "...", types: ["product"])
5. update_slots([{slotId, selectedProductId}, ...])
6. upsert_custom_field_values(...)
```
### 8.3 Créer un Produit
```
1. list_model_types(category: "product")
2. create_product(name, reference, modelTypeId, prix, constructeurs)
3. upsert_custom_field_values(...)
```
### 8.4 Créer une Machine complète (de bas en haut)
```
1. Créer les produits nécessaires (§8.3)
2. Créer les pièces avec les produits dans les slots (§8.2)
3. Créer les composants avec les pièces dans les slots (§8.1)
4. list_sites → choisir le site
5. create_machine(name, reference, siteId, constructeurs)
6. add_machine_links(machineId, links: [
{type: "composant", entityId, quantity},
{type: "piece", entityId, quantity},
{type: "product", entityId}
])
7. upsert_custom_field_values(entityType: "machine", machineId, ...)
```
## 9. Pagination
Toutes les tools `list_*` utilisent un contrat de pagination uniforme :
### Paramètres d'entrée
| Paramètre | Type | Default | Description |
|---|---|---|---|
| `page` | int | 1 | Numéro de page (1-indexed) |
| `limit` | int | 30 | Nombre d'items par page (max 100) |
### Format de réponse
```json
{
"items": [...],
"total": 142,
"page": 1,
"limit": 30,
"pageCount": 5
}
```
## 10. Format des erreurs
Toutes les erreurs MCP suivent un format uniforme via `isError: true` dans la réponse tool :
```json
{
"isError": true,
"content": [{"type": "text", "text": "Permission denied: ROLE_GESTIONNAIRE required for create_machine"}]
}
```
### Catégories d'erreurs
| Code | Description | Exemple |
|---|---|---|
| `auth_error` | Credentials invalides ou manquants | "Authentication failed: invalid password" |
| `permission_denied` | Rôle insuffisant pour l'opération | "Permission denied: ROLE_GESTIONNAIRE required" |
| `not_found` | Entité introuvable | "Machine not found: cl4a8b..." |
| `validation_error` | Données invalides | "Validation failed: name is required" |
| `rate_limited` | Trop de tentatives d'auth échouées | "Rate limited: try again in 45 seconds" |
| `internal_error` | Erreur serveur inattendue | "Internal error: database connection failed" |
Le champ `text` inclut toujours la catégorie en préfixe pour que le LLM puisse adapter son comportement.
## 11. Configuration
### 11.1 Symfony — config/packages/mcp.yaml
```yaml
mcp:
app: 'inventory'
version: '%env(file:resolve:VERSION)%'
description: 'Inventory MCP Server - Gestion inventaire industriel (machines, pièces, composants, produits)'
instructions: |
Serveur MCP pour gérer un inventaire industriel.
Entités principales : Machine, Composant, Pièce, Produit, Site, Constructeur.
Utilisez search_inventory pour chercher dans toutes les entités.
Utilisez get_model_type pour comprendre la structure attendue avant de créer un composant ou une pièce.
Consultez la resource inventory://schema/entities pour voir le schéma complet.
Authentification requise : envoyez X-Profile-Id et X-Profile-Password dans les headers HTTP.
client_transports:
stdio: true
http: true
http:
path: /_mcp
session:
store: file
directory: '%kernel.cache_dir%/mcp-sessions'
ttl: 3600
```
### 11.2 Security — config/packages/security.yaml (ajout firewall)
```yaml
security:
firewalls:
# AVANT le firewall api existant
mcp:
pattern: ^/_mcp
stateless: true
custom_authenticators:
- App\Mcp\Security\McpHeaderAuthenticator
api:
pattern: ^/api
# ... existant ...
```
### 11.3 Rate Limiter — config/packages/rate_limiter.yaml
```yaml
framework:
rate_limiter:
mcp_auth:
policy: sliding_window
limit: 5
interval: '1 minute'
```
### 11.4 Routes — config/routes.yaml (ajout)
```yaml
mcp:
resource: .
type: mcp
```
### 11.5 Logging — config/packages/monolog.yaml (ajout)
```yaml
monolog:
channels: ['mcp']
handlers:
mcp:
type: rotating_file
path: '%kernel.logs_dir%/mcp.log'
level: info
channels: ['mcp']
max_files: 30
```
## 12. Configuration des clients
### 12.1 Claude Code (local, stdio via Docker)
Fichier `.mcp.json` à la racine du projet :
```json
{
"mcpServers": {
"inventory": {
"command": "docker",
"args": [
"exec", "-i",
"-e", "MCP_PROFILE_ID=<votre-profile-id>",
"-e", "MCP_PROFILE_PASSWORD=<votre-password>",
"php-inventory-apache",
"php", "bin/console", "mcp:server"
]
}
}
}
```
> **Note :** Les env vars sont passées via les flags `-e` de `docker exec` car le bloc `env` de `.mcp.json` ne les injecte pas dans le container Docker. Si PHP et les dépendances Composer sont disponibles directement sur l'hôte (hors Docker), on peut utiliser `"command": "php", "args": ["bin/console", "mcp:server"]` avec un bloc `env` standard.
### 12.2 Claude Desktop (distant, HTTP via tunnel)
Fichier `claude_desktop_config.json` :
```json
{
"mcpServers": {
"inventory": {
"url": "https://inventory.company-tunnel.com/_mcp",
"headers": {
"X-Profile-Id": "<votre-profile-id>",
"X-Profile-Password": "<votre-password>"
}
}
}
}
```
### 12.3 ChatGPT Desktop (HTTP via tunnel)
Même principe HTTP : URL du tunnel + headers d'auth. Format de config selon la doc ChatGPT MCP.
### 12.4 Codex (HTTP via tunnel)
Même config HTTP que Claude Desktop.
## 13. Structure des fichiers
```
src/
└── Mcp/
├── Tool/
│ ├── SearchInventoryTool.php # search_inventory
│ ├── DashboardStatsTool.php # get_dashboard_stats
│ ├── ActivityLogTool.php # get_activity_log
│ ├── EntityHistoryTool.php # get_entity_history
│ ├── Machine/
│ │ ├── ListMachinesTool.php # list_machines
│ │ ├── GetMachineTool.php # get_machine
│ │ ├── CreateMachineTool.php # create_machine
│ │ ├── UpdateMachineTool.php # update_machine
│ │ ├── DeleteMachineTool.php # delete_machine
│ │ ├── MachineStructureTool.php # get_machine_structure
│ │ ├── CloneMachineTool.php # clone_machine
│ │ ├── ListMachineLinksTool.php # list_machine_links
│ │ ├── AddMachineLinksTool.php # add_machine_links
│ │ ├── UpdateMachineLinkTool.php # update_machine_link
│ │ └── RemoveMachineLinkTool.php # remove_machine_link
│ ├── Composant/
│ │ ├── ListComposantsTool.php # list_composants
│ │ ├── GetComposantTool.php # get_composant
│ │ ├── CreateComposantTool.php # create_composant
│ │ ├── UpdateComposantTool.php # update_composant
│ │ └── DeleteComposantTool.php # delete_composant
│ ├── Piece/
│ │ ├── ListPiecesTool.php # list_pieces
│ │ ├── GetPieceTool.php # get_piece
│ │ ├── CreatePieceTool.php # create_piece
│ │ ├── UpdatePieceTool.php # update_piece
│ │ └── DeletePieceTool.php # delete_piece
│ ├── Slot/
│ │ ├── ListSlotsTool.php # list_slots (dispatche par entityType)
│ │ └── UpdateSlotsTool.php # update_slots
│ ├── Product/
│ │ ├── ListProductsTool.php # list_products
│ │ ├── GetProductTool.php # get_product
│ │ ├── CreateProductTool.php # create_product
│ │ ├── UpdateProductTool.php # update_product
│ │ └── DeleteProductTool.php # delete_product
│ ├── Site/
│ │ ├── ListSitesTool.php # list_sites
│ │ ├── GetSiteTool.php # get_site
│ │ ├── CreateSiteTool.php # create_site
│ │ ├── UpdateSiteTool.php # update_site
│ │ └── DeleteSiteTool.php # delete_site
│ ├── Constructeur/
│ │ ├── ListConstructeursTool.php # list_constructeurs
│ │ ├── GetConstructeurTool.php # get_constructeur
│ │ ├── CreateConstructeurTool.php # create_constructeur
│ │ ├── UpdateConstructeurTool.php # update_constructeur
│ │ └── DeleteConstructeurTool.php # delete_constructeur
│ ├── ModelType/
│ │ ├── ListModelTypesTool.php # list_model_types
│ │ ├── GetModelTypeTool.php # get_model_type
│ │ ├── CreateModelTypeTool.php # create_model_type
│ │ ├── UpdateModelTypeTool.php # update_model_type
│ │ ├── DeleteModelTypeTool.php # delete_model_type
│ │ └── SyncModelTypeTool.php # sync_model_type
│ ├── CustomField/
│ │ ├── ListCustomFieldValuesTool.php # list_custom_field_values
│ │ ├── UpsertCustomFieldValuesTool.php # upsert_custom_field_values
│ │ └── DeleteCustomFieldValueTool.php # delete_custom_field_value
│ ├── Document/
│ │ ├── ListDocumentsTool.php # list_documents
│ │ └── DeleteDocumentTool.php # delete_document
│ └── Comment/
│ ├── ListCommentsTool.php # list_comments
│ ├── CreateCommentTool.php # create_comment
│ ├── ResolveCommentTool.php # resolve_comment
│ └── UnresolvedCountTool.php # get_unresolved_comments_count
├── Resource/
│ ├── SchemaResource.php # inventory://schema/entities
│ ├── ModelTypesResource.php # inventory://model-types/{category}
│ ├── RolesResource.php # inventory://roles
│ └── StatsResource.php # inventory://stats
└── Security/
└── McpHeaderAuthenticator.php # Symfony Authenticator pour firewall MCP
docs/
└── mcp/
└── README.md # Guide utilisateur complet
```
## 14. Documentation utilisateur (docs/mcp/README.md)
Le guide contiendra :
1. **Introduction** — Qu'est-ce que le MCP Inventory, à quoi ça sert, quels clients sont supportés
2. **Prérequis** — Profil avec rôle suffisant, accès au tunnel, client MCP compatible
3. **Installation & configuration par client** — Exemples copier-coller pour :
- Claude Code (stdio via Docker)
- Claude Desktop (HTTP via tunnel)
- ChatGPT Desktop (HTTP via tunnel)
- Codex (HTTP via tunnel)
4. **Catalogue des tools** — Tableau complet avec nom, description, paramètres, rôle requis
5. **Workflows guidés** — Comment créer une machine, un composant, une pièce, un produit (étape par étape avec exemples d'appels)
6. **Resources disponibles** — URIs et contenu exposé
7. **Rôles & permissions** — Quel rôle permet quelles actions
8. **Format des erreurs** — Catégories et exemples
9. **Limitations connues** — Upload documents non supporté via MCP
10. **Troubleshooting** — Erreurs courantes (auth failed, tunnel down, rôle insuffisant, rate limited)
## 15. Sécurité
| Mesure | Détail |
|---|---|
| **Firewall Symfony** | `/_mcp` a son propre firewall avec `McpHeaderAuthenticator` — intégré au système de sécurité standard |
| **Vérification rôle** | Chaque tool vérifie via `$security->isGranted()` avec hiérarchie des rôles |
| **Audit trail** | `AbstractAuditSubscriber.resolveActorProfileId()` fonctionne nativement car `$security->getUser()` retourne le Profile authentifié |
| **Rate limiting** | 5 tentatives d'auth échouées par minute par IP → rejet |
| **Transport chiffré** | Le tunnel assure le chiffrement en transit pour les clients distants |
| **Pas de secrets dans le code** | Credentials dans env vars (stdio) ou headers (HTTP), jamais en dur |
| **Sessions MCP** | TTL 1h, stockage fichier, nettoyage automatique |
| **CORS** | Non nécessaire — les clients MCP sont des apps natives (pas des navigateurs). Le tunnel termine la connexion côté serveur. À réévaluer si un client browser-based apparaît |
## 16. Backward Compatibility
Les tools MCP suivent une politique additive :
- **Ajouts** : nouveaux tools, nouveaux paramètres optionnels → toujours OK
- **Suppressions** : marquer un tool comme deprecated pendant 1 version avant suppression
- **Breaking changes** : changer le type/nom d'un paramètre requis → bumper la version MCP
Le champ `version` dans la config MCP (lu depuis `VERSION`) signale les changements.
## 17. Dépendances à installer
```bash
composer require symfony/mcp-bundle symfony/rate-limiter
```
Le bundle tire `mcp/sdk` automatiquement.
## 18. Tests
Les tools MCP seront testés via :
- **Tests unitaires** : chaque tool testé avec des mocks de repositories, vérification des paramètres et des réponses
- **Tests d'intégration** : appels MCP stdio via `docker exec ... php bin/console mcp:server` avec des fixtures
- **Tests de sécurité** : vérification que les tools rejettent les appels sans auth, avec rôle insuffisant, et après rate limiting
- Pattern : hériter de `AbstractApiTestCase` pour réutiliser les factories existantes (`createProfile()`, `createMachine()`, etc.)
## 19. Spike / PoC initial
Avant l'implémentation complète, une étape de validation :
1. Installer `symfony/mcp-bundle` dans le projet
2. Créer un tool minimal (`get_dashboard_stats`) avec l'attribut `#[McpTool]`
3. Tester le transport stdio : `docker exec -i php-inventory-apache php bin/console mcp:server`
4. Tester le transport HTTP : appel POST sur `/_mcp`
5. Valider que l'authenticator custom fonctionne avec le firewall
6. Confirmer que `$security->getUser()` retourne le bon Profile dans un tool
Si le PoC révèle des incompatibilités avec l'API du bundle, adapter le design avant de continuer.

View File

@@ -0,0 +1,138 @@
# Document Types — Design Spec
Date: 2026-03-23
Status: Approved
## Goal
Add a `type` field to documents so users can classify them (documentation, devis, facture, plan, photo, autre). Users can set the type at upload and change it afterward via a mini-modal.
## Enum Values
| Value | Label |
|-------|-------|
| `documentation` | Documentation |
| `devis` | Devis |
| `facture` | Facture |
| `plan` | Plan |
| `photo` | Photo |
| `autre` | Autre |
Default: `documentation`
## Backend
### 1. PHP Enum
New file: `src/Enum/DocumentType.php`
```php
enum DocumentType: string
{
case DOCUMENTATION = 'documentation';
case DEVIS = 'devis';
case FACTURE = 'facture';
case PLAN = 'plan';
case PHOTO = 'photo';
case AUTRE = 'autre';
}
```
### 2. Entity Change — Document.php
Add column:
```php
#[ORM\Column(type: Types::STRING, length: 20, enumType: DocumentType::class)]
#[Groups(['document:list'])]
private DocumentType $type = DocumentType::DOCUMENTATION;
```
Add getter/setter:
```php
public function getType(): DocumentType { ... }
public function setType(DocumentType $type): static { ... }
```
### 3. API Platform — PATCH operation
Add a `Patch` operation on Document (ROLE_GESTIONNAIRE) to allow updating `name` and `type`. The existing `Put` already exists but PATCH is more appropriate for partial updates.
### 4. DocumentUploadProcessor
Accept optional `type` field from FormData. Validate against enum values, default to `documentation` if absent.
### 5. Migration
```sql
ALTER TABLE documents ADD COLUMN type VARCHAR(20) NOT NULL DEFAULT 'documentation';
-- Classify existing documents by mimeType
UPDATE documents SET type = 'photo' WHERE mimetype LIKE 'image/%';
UPDATE documents SET type = 'autre'
WHERE type = 'documentation'
AND mimetype NOT LIKE 'application/pdf'
AND mimetype NOT LIKE 'image/%';
```
### 6. DocumentQueryController
Add `type` to the response array in `formatDocument()`.
## Frontend
### 1. Type Constants
New file: `app/shared/documentTypes.ts`
```typescript
export const DOCUMENT_TYPES = [
{ value: 'documentation', label: 'Documentation' },
{ value: 'devis', label: 'Devis' },
{ value: 'facture', label: 'Facture' },
{ value: 'plan', label: 'Plan' },
{ value: 'photo', label: 'Photo' },
{ value: 'autre', label: 'Autre' },
] as const
export type DocumentTypeValue = typeof DOCUMENT_TYPES[number]['value']
```
### 2. DocumentUpload.vue — Type select at upload
Add a select dropdown (default: `documentation`) in the upload zone. The selected type applies to all files in the current batch. Pass the type through to `uploadDocuments()`.
### 3. useDocuments composable
- `uploadDocuments()`: accept `type` in the upload context, append to FormData
- New method: `updateDocument(id, { name, type })` — PATCH `/api/documents/{id}` with `application/merge-patch+json`
- Add `type` to the `Document` interface
### 4. DocumentEditModal.vue (new component)
Mini-modal with:
- Input text: document name (pre-filled)
- Select: document type (pre-filled)
- Buttons: Annuler / Sauvegarder
- On save: call `updateDocument()`, emit `updated` event
### 5. Document list display
Everywhere documents are listed (machine detail, composant edit, piece edit, product, site):
- Show type as a small badge next to the document name
- Add a pencil/edit button that opens `DocumentEditModal`
- On modal save: refresh the document in local state
## Migration of existing data
All existing documents classified by mimeType:
- `image/*``photo`
- `application/pdf``documentation`
- Everything else → `autre`
## Out of scope
- Custom user-defined types (table `document_types`) — can be added later
- Filtering documents by type in the UI — can be added later
- Bulk type change

View File

@@ -0,0 +1,88 @@
# Parc Machines — Améliorations UX
**Date** : 2026-03-23
**Scope** : 3 changements sur le frontend + 1 extension backend
---
## 1. Filtre sites multi-sélection par checkboxes
### Contexte
Le filtre site actuel est un `<select>` mono-sélection dans `machines/index.vue`.
L'utilisateur veut pouvoir sélectionner plusieurs sites simultanément.
### Design
- Remplacer le `<select>` par une rangée de checkboxes DaisyUI directement visibles dans la barre de filtre.
- Chaque site = une checkbox avec le nom du site.
- Quand **aucune** checkbox n'est cochée → toutes les machines s'affichent (équivalent "Tous les sites").
- Quand **une ou plusieurs** sont cochées → filtre sur ces sites uniquement.
### Changements techniques
**Fichier** : `Inventory_frontend/app/pages/machines/index.vue`
- **Réactivité** : utiliser `reactive(new Set())` (Vue 3.4+ supporte nativement les mutations `add`/`delete`/`has` sur un Set réactif). Pas de `.value` nécessaire.
- **Note** : le fichier utilise `<script setup>` sans `lang="ts"` — ne pas utiliser d'annotations TypeScript comme `Set<string>`.
- Template : remplacer le `<select>` par un `div` flex-wrap avec des checkboxes DaisyUI (`checkbox checkbox-sm`) + label pour chaque site.
- Computed `filteredMachines` : remplacer `machine.siteId === selectedSite` par `selectedSites.size === 0 || selectedSites.has(machine.siteId)`.
---
## 2. Tri alphabétique croissant
### Contexte
Les machines s'affichent dans l'ordre retourné par l'API, sans tri. L'utilisateur veut un tri alphabétique croissant par nom.
### Design
Ajouter un `.sort()` avec `localeCompare('fr')` à la fin du computed `filteredMachines`.
### Changements techniques
**Fichier** : `Inventory_frontend/app/pages/machines/index.vue`
- Dans le computed `filteredMachines`, ajouter avant le `return` :
```js
filtered = [...filtered].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr')
)
```
---
## 3. Recherche par référence dans les catalogues (Pièces, Composants, Produits)
### Contexte
Les placeholders des champs de recherche promettent "Nom ou référence…" mais le frontend n'envoie que `?name=xxx` à l'API. Le backend (API Platform SearchFilter) supporte `name` et `reference` en `ipartial`, mais combiner `?name=xxx&reference=xxx` produit un AND (les deux doivent matcher), pas un OR.
### Design
Créer une **Extension Doctrine** (`SearchByNameOrReferenceExtension`) qui intercepte un paramètre `?q=xxx` et ajoute une clause `WHERE name ILIKE %xxx% OR reference ILIKE %xxx%` à la requête. Côté frontend, remplacer `params.set('name', search)` par `params.set('q', search)`.
### Changements techniques
**Backend — Nouveau fichier** : `src/Doctrine/SearchByNameOrReferenceExtension.php`
- Implémente `QueryCollectionExtensionInterface`
- S'applique aux entités `Piece`, `Composant`, `Product`
- Lit le paramètre `q` depuis la requête HTTP
- Ajoute `LOWER(o.name) LIKE :searchQ OR LOWER(o.reference) LIKE :searchQ` avec paramètre `%{strtolower(q)}%`
- **Échappement LIKE** : les caractères `%` et `_` dans l'input utilisateur sont échappés via `addcslashes($q, '%_')` pour éviter des matchs trop larges
- **`reference` nullable** : les lignes avec `reference = NULL` ne matcheront pas (comportement SQL standard : `NULL LIKE x` = NULL = false), ce qui est le comportement attendu
- **Pas de conflit** avec le `SearchFilter` existant : le paramètre `q` n'est pas enregistré comme propriété de `SearchFilter`, donc il sera ignoré par celui-ci. Les filtres `name` et `reference` restent disponibles pour d'autres usages.
**Frontend — 3 fichiers** (dans la fonction `loadXxx`, remplacer l'appel `params.set('name', search.trim())`) :
- `Inventory_frontend/app/composables/usePieces.ts` → `params.set('q', search.trim())`
- `Inventory_frontend/app/composables/useComposants.ts` → idem
- `Inventory_frontend/app/composables/useProducts.ts` → idem
---
## Fichiers impactés (résumé)
| Fichier | Changement |
|---------|-----------|
| `Inventory_frontend/app/pages/machines/index.vue` | Checkboxes sites + tri alphabétique |
| `src/Doctrine/SearchByNameOrReferenceExtension.php` | **Nouveau** — Extension Doctrine OR search |
| `Inventory_frontend/app/composables/usePieces.ts` | `name` → `q` |
| `Inventory_frontend/app/composables/useComposants.ts` | `name` → `q` |
| `Inventory_frontend/app/composables/useProducts.ts` | `name` → `q` |
## Hors scope
- La page Parc Machines cherche **déjà** sur nom ET référence côté frontend (filtrage client-side). Pas de changement nécessaire.
- Aucun changement de placeholder — ils affichent déjà "Nom ou référence…".

View File

@@ -0,0 +1,312 @@
# Entity Versioning — Design Spec
**Date :** 2026-03-25
**Entites concernees :** Machine, Composant, Piece, Produit
**Approche :** Extension du systeme AuditLog existant
---
## Objectif
Permettre de consulter l'historique des versions numerotees (v1, v2, v3...) des entites principales et de restaurer n'importe quelle version anterieure, afin de ne jamais perdre de donnees.
---
## Regles metier
### Creation de version
- Chaque `create` ou `update` sur une entite incremente automatiquement le compteur `version` de l'entite
- Le numero de version est enregistre dans l'AuditLog correspondant (nouvelle colonne `version`)
### Restauration
- La restauration cree une **nouvelle version** (v+1) — on ne supprime jamais d'historique
- Le service `EntityVersionService::restore()` cree **manuellement** un AuditLog avec `action = "restore"` et le diff contient `restoredFromVersion: N`
- Important : le flush du restore declenche les AuditSubscribers, qui produiraient un `update` duplique. Pour eviter cela, l'entite porte un flag transitoire `$skipAudit = true` que les subscribers verifient
### Controle de squelette (Composant, Piece, Produit uniquement)
- Avant restauration, on compare le ModelType actuel avec celui du snapshot
- **Meme squelette (ModelType)** : restore complet — champs de base + slots + custom fields
- **Squelette different** : restore partiel — uniquement les champs de base (nom, description, reference, constructeurs, prix)
### Controle d'integrite
- Avant restauration, on verifie que toutes les entites liees dans le snapshot existent encore en base :
- **Composant** : pieces selectionnees dans les slots, produits, sous-composants, constructeurs
- **Piece** : produits selectionnes dans les slots, constructeurs
- **Produit** : constructeurs
- **Machine** : site, liens composants/pieces/produits (MachineComponentLink, MachinePieceLink, MachineProductLink)
- Les entites manquantes generent des **warnings** affiches a l'utilisateur
- Les slots avec des entites supprimees sont restaures **vides** (sans selection)
- Pour les custom field values : restauration par `fieldId` + entite parente (pas par ID de la CustomFieldValue elle-meme, car un sync ModelType peut recreer les CFV avec des IDs differents)
- Les controles d'integrite utilisent des requetes batch (`findBy(['id' => $ids])`) plutot que des requetes individuelles par slot
### Machines
- Pas de controle de squelette (pas de ModelType) : restauration toujours complete
- Controle d'integrite sur le site et les liens machine
- Machine n'a pas de champ `description` (contrairement aux autres entites)
### Permissions
- Consulter les versions : `ROLE_VIEWER`
- Restaurer une version : `ROLE_GESTIONNAIRE` et au-dessus
---
## Modifications backend
### 1. Colonne `version` sur AuditLog
```sql
ALTER TABLE audit_logs ADD COLUMN version INT DEFAULT NULL;
```
Nullable car les AuditLogs existants n'ont pas de version.
### 2. Colonne `version` sur Machine
```sql
ALTER TABLE machine ADD COLUMN version INT NOT NULL DEFAULT 1;
```
Les entites Composant, Piece, Produit ont deja cette colonne.
### 3. Enrichissement des snapshots
Les Audit Subscribers doivent inclure dans le `snapshot` :
**Composant :**
```json
{
"id": "cl...",
"name": "...",
"reference": "...",
"description": "...",
"prix": 100.00,
"typeComposant": { "id": "cl...", "name": "...", "code": "..." },
"product": { "id": "cl...", "name": "..." },
"constructeurIds": [{ "id": "cl...", "name": "..." }],
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
"pieceSlots": [
{ "id": "cl...", "typePieceId": "cl...", "selectedPieceId": "cl...", "quantity": 1, "position": 0 }
],
"subcomponentSlots": [
{ "id": "cl...", "alias": "...", "familyCode": "...", "typeComposantId": "cl...", "selectedComposantId": "cl...", "position": 0 }
],
"productSlots": [
{ "id": "cl...", "typeProductId": "cl...", "selectedProductId": "cl...", "familyCode": "...", "position": 0 }
],
"version": 3
}
```
**Piece :**
```json
{
"id": "cl...",
"name": "...",
"reference": "...",
"description": "...",
"prix": 50.00,
"typePiece": { "id": "cl...", "name": "...", "code": "..." },
"product": { "id": "cl...", "name": "..." },
"constructeurIds": [{ "id": "cl...", "name": "..." }],
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
"productSlots": [
{ "id": "cl...", "typeProductId": "cl...", "selectedProductId": "cl...", "familyCode": "...", "position": 0 }
],
"version": 2
}
```
**Produit :**
```json
{
"id": "cl...",
"name": "...",
"reference": "...",
"supplierPrice": 25.00,
"typeProduct": { "id": "cl...", "name": "...", "code": "..." },
"constructeurIds": [{ "id": "cl...", "name": "..." }],
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
"version": 1
}
```
**Machine :**
```json
{
"id": "cl...",
"name": "...",
"reference": "...",
"prix": 1500.00,
"site": { "id": "cl...", "name": "..." },
"constructeurIds": [{ "id": "cl...", "name": "..." }],
"customFieldValues": [{ "id": "cl...", "fieldName": "...", "value": "..." }],
"version": 4
}
```
### 4. Incrementation automatique de la version
Dans chaque Audit Subscriber, a chaque `create`/`update` :
1. Appeler `$entity->incrementVersion()`
2. Ecrire `$auditLog->setVersion($entity->getVersion())`
Pour Machine, ajouter la methode `incrementVersion()` et la propriete `version` a l'entite.
### 5. Nouveaux endpoints — `EntityVersionController`
| Methode | Route | Description | Role |
|---------|-------|-------------|------|
| GET | `/api/{entity}/{id}/versions` | Liste des versions | ROLE_VIEWER |
| GET | `/api/{entity}/{id}/versions/{version}/preview` | Preview + controles avant restore | ROLE_GESTIONNAIRE |
| POST | `/api/{entity}/{id}/versions/{version}/restore` | Execute la restauration | ROLE_GESTIONNAIRE |
`{entity}` = `machines`, `composants`, `pieces`, `products`
**GET versions — Response :**
```json
{
"items": [
{
"version": 3,
"action": "update",
"createdAt": "2026-03-25T14:30:00+00:00",
"actor": { "id": "cl...", "label": "Jean Dupont" },
"diff": { "name": { "from": "Ancien", "to": "Nouveau" } }
}
],
"total": 3
}
```
**GET preview — Response :**
```json
{
"version": 2,
"restoreMode": "full",
"diff": {
"name": { "current": "Nouveau", "restored": "Ancien" },
"reference": { "current": "REF-002", "restored": "REF-001" }
},
"warnings": [
{
"field": "pieceSlots[0].selectedPieceId",
"message": "La piece 'Roulement XY' (cl...) n'existe plus. Le slot sera restaure vide.",
"missingEntityId": "cl...",
"missingEntityName": "Roulement XY"
}
],
"snapshot": { }
}
```
`restoreMode` : `"full"` (meme squelette) ou `"partial"` (squelette different, champs de base uniquement).
**POST restore — Response :**
```json
{
"success": true,
"newVersion": 6,
"restoredFromVersion": 2,
"restoreMode": "full",
"warnings": []
}
```
### 6. Service `EntityVersionService`
Service centralise pour la logique de versioning :
- `getVersions(string $entityType, string $entityId): array` — liste des versions depuis AuditLog
- `getRestorePreview(string $entityType, string $entityId, int $version): array` — controles + diff
- `restore(string $entityType, string $entityId, int $version): array` — execution du restore
Methodes internes :
- `checkSkeletonCompatibility(object $entity, array $snapshot): string` — retourne `"full"` ou `"partial"`
- `checkIntegrity(string $entityType, array $snapshot): array` — retourne les warnings
- `applyRestore(object $entity, array $snapshot, string $mode): void` — applique les changements
---
## Modifications frontend
### 1. Composant `EntityVersionList.vue`
Composant reutilisable affiche dans un onglet "Versions" sur les pages de detail.
**Props :**
- `entityType: 'machines' | 'composants' | 'pieces' | 'products'`
- `entityId: string`
**Affichage :**
- Tableau : version, date, auteur, action, diff resume
- Badge "Actuelle" sur la version la plus recente
- Bouton "Restaurer" sur chaque ligne (sauf version actuelle), visible uniquement pour ROLE_GESTIONNAIRE+
### 2. Composant `VersionRestoreModal.vue`
Modal de confirmation avec preview.
**Props :**
- `entityType`, `entityId`, `version` (cible)
- `previewData` (resultat du GET preview)
**Affichage :**
- Indicateur de mode : "Restauration complete" ou "Restauration partielle"
- Diff visuel : champs qui changent (valeur actuelle -> valeur restauree)
- Warnings en alerte orange pour les entites manquantes
- Boutons "Confirmer la restauration" / "Annuler"
### 3. Composable `useEntityVersions.ts`
```typescript
interface Deps {
entityType: MaybeRef<string>
entityId: MaybeRef<string>
}
export function useEntityVersions(deps: Deps) {
// fetchVersions() — GET /api/{entity}/{id}/versions
// fetchPreview(version) — GET /api/{entity}/{id}/versions/{version}/preview
// restore(version) — POST /api/{entity}/{id}/versions/{version}/restore
}
```
### 4. Integration dans les pages de detail
Ajouter un onglet "Versions" dans les pages :
- `pages/machines/[id].vue`
- `pages/composants/[id].vue`
- `pages/pieces/[id].vue`
- `pages/products/[id].vue`
L'onglet affiche `EntityVersionList` qui gere l'ouverture de `VersionRestoreModal`.
---
## Migration
Une seule migration PostgreSQL :
```sql
-- Colonne version sur audit_logs
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS version INT DEFAULT NULL;
-- Colonne version sur machine
ALTER TABLE machine ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1;
-- Index pour requetes par version
CREATE INDEX IF NOT EXISTS idx_audit_entity_version ON audit_logs (entity_type, entity_id, version);
```
---
## Ce qui change (breaking)
- **Piece snapshot** : le champ legacy `productIds` (ancien JSON) est remplace par `productSlots` (tables normalisees). Les anciens AuditLogs conservent `productIds` dans leur snapshot mais les nouveaux ne l'auront plus. Le restore utilise `productSlots` exclusivement.
## Ce qui ne change PAS
- L'onglet/page d'historique existant (`EntityHistoryController`) reste inchange
- Les AuditLogs existants (sans version) continuent de fonctionner
- Le mecanisme d'audit automatique via les Subscribers reste identique, juste enrichi
- Les documents ne sont pas versionnes (hors scope)

View File

@@ -0,0 +1,117 @@
# Machine : Bouton Save Unique + Versioning des Liens
**Date :** 2026-03-26
**Statut :** Approuvé
## Contexte
La page machine utilise actuellement un auto-save au blur pour chaque champ (info, custom fields, constructeurs). Les pages composant/pièce/produit utilisent un bouton unique "Enregistrer les modifications" en bas du formulaire. L'objectif est d'aligner la page machine sur ce pattern.
De plus, les ajouts/suppressions de liens composant/pièce/produit sur une machine ne sont pas tracés dans le versioning. Ils doivent l'être.
## Volet 1 : Bouton Save Unique
### Comportement cible
- En mode édition, tous les champs (info machine, custom field values, custom field definitions, constructeurs) sont modifiés localement sans appel API.
- Un bouton "Enregistrer les modifications" en bas du formulaire sauvegarde tout d'un coup.
- Un bouton "Annuler" réinitialise l'état local et sort du mode édition.
- Les documents restent en upload/suppression immédiate (inchangé).
- Les ajouts/suppressions de liens composant/pièce/produit restent immédiats via modales (inchangé).
### Changements frontend
#### MachineInfoCard.vue
- Supprimer les `@blur``$emit('blur-field')` sur les inputs (nom, référence)
- Supprimer le `@change` qui émet `blur-field` sur le select site
- Supprimer les `@blur``$emit('update-custom-field', field)` sur tous les champs custom
- Conserver `@input` / `@update:*` / `set-custom-field-value` pour la mise à jour de l'état local
- Le `MachineCustomFieldDefEditor` perd son bouton save propre : l'état est collecté au submit global
#### machine/[id].vue
- Supprimer le handler `@blur-field`
- Supprimer le handler `@update-custom-field`
- `@update:constructeur-ids` met à jour l'état local sans save
- Ajouter le bloc boutons en bas (pattern identique à component/[id]/index.vue) :
- "Annuler" (btn-ghost) → `cancelEdition()` : réinitialise depuis `machine.value` + sort du mode édition
- "Enregistrer les modifications" (btn-primary, disabled si `!canSubmit`) → `submitEdition()`
#### useMachineDetailData.ts
- Exposer `saving` ref
- Exposer `submitEdition()` :
1. `updateMachineInfo()` — PATCH machine (nom, ref, site, constructeurs)
2. Batch save custom field values (tous les `visibleMachineCustomFields` avec valeur)
3. Save custom field definitions si modifiées (`fieldDefs.saveDefinitions()`)
4. `loadMachineData()` pour recharger
5. Sortie du mode édition + toast succès
- Exposer `cancelEdition()` :
1. `initMachineFields()` — réinitialise nom, ref, site, constructeurs depuis `machine.value`
2. `syncMachineCustomFields()` — réinitialise les custom fields
3. Sort du mode édition
#### useMachineDetailUpdates.ts
- `handleMachineConstructeurChange` ne déclenche plus `updateMachineInfo()`, met juste à jour le ref local
#### useMachineDetailCustomFields.ts
- `updateMachineCustomField` n'est plus appelé au blur — sera appelé en batch par `submitEdition()`
- Ajouter méthode `saveAllMachineCustomFields()` qui itère sur les champs visibles et sauvegarde ceux avec valeur
### Validation (`canSubmit`)
- Machine existe
- Nom non vide
- Pas en cours de sauvegarde (`!saving.value`)
- `canEdit` est true
## Volet 2 : Versioning des Liens Machine
### Comportement cible
Quand un composant, pièce ou produit est ajouté ou supprimé d'une machine, cela doit :
1. Incrémenter la `version` de la Machine
2. Créer une entrée `AuditLog` avec diff et snapshot
### Changements backend
#### MachineAuditSubscriber — enrichir le snapshot
Ajouter au snapshot machine les liens :
```php
'componentLinks' => array_map(fn($link) => [
'id' => $link->getId(),
'composantId' => $link->getComposant()->getId(),
'composantName' => $link->getComposant()->getName(),
], $entity->getComponentLinks()->toArray()),
'pieceLinks' => [...],
'productLinks' => [...],
```
#### Nouveau subscriber ou service : MachineLinkAuditService
Écouter les événements Doctrine `postPersist` et `postRemove` sur les 3 entités link.
Quand un lien est créé/supprimé :
1. Récupérer la Machine parente
2. Incrémenter `$machine->incrementVersion()`
3. Créer un `AuditLog` :
- `entityType: 'machine'`
- `entityId: $machine->getId()`
- `action: 'update'`
- `diff: { addedComponent: {id, name} }` ou `{ removedPiece: {id, name} }`
- `snapshot:` snapshot complet de la machine (avec liens mis à jour)
- `version:` nouvelle version
### Labels pour le diff (frontend)
Ajouter au `historyFieldLabels` de la page machine :
```js
addedComponent: 'Composant ajouté',
removedComponent: 'Composant supprimé',
addedPiece: 'Pièce ajoutée',
removedPiece: 'Pièce supprimée',
addedProduct: 'Produit ajouté',
removedProduct: 'Produit supprimé',
```
## Ce qui ne change PAS
- Upload/suppression de documents (immédiat)
- Pattern read/edit toggle dans le header
- L'affichage des sections composants/pièces/produits
- Les modales d'ajout/suppression de liens (restent immédiates)
- Le versioning des autres entités (composant, pièce, produit)

View File

@@ -0,0 +1,60 @@
# Spec : Formula Builder interactif pour la référence auto
**Date** : 2026-03-31
**Scope** : Frontend uniquement (pas de changement backend)
**Fichier impacté** : `Inventory_frontend/app/components/model-types/ModelTypeForm.vue`
## Problème
L'utilisateur doit taper manuellement les noms exacts des custom fields dans la formule (`{serie}{diametre}{type}`) et re-lister les champs requis séparés par des virgules. C'est sujet aux erreurs de typo et peu ergonomique.
## Solution
Remplacer la section "Génération de référence automatique" du `ModelTypeForm` par un formula builder interactif.
### Composants UI
#### 1. Chips de champs disponibles
- Afficher une rangée de boutons-chips avec les noms des custom fields définis dans `pieceStructure.customFields`
- Cliquer sur un chip insère `{nom_du_champ}` dans l'input formule à la position du curseur
- Si `pieceStructure.customFields` est vide, afficher un message "Aucun champ personnalisé défini"
#### 2. Input formule
- Input texte classique (comme aujourd'hui) mais avec les chips comme aide à la saisie
- L'utilisateur peut aussi taper du texte libre (séparateurs `-`, `/`, préfixes `SNU `, etc.)
- Le format stocké reste `{nom_du_champ}` — aucun changement de format backend
#### 3. Suppression du champ "Champs requis"
- Le champ `requiredFieldsForReference` est calculé automatiquement au submit en extrayant tous les `{...}` de la formule
- Suppression de l'input "Champs requis" et de la variable `requiredFieldsInput`
- La logique : tous les champs présents dans la formule sont requis. Si un champ n'a pas de valeur → pas de référence générée
#### 4. Aperçu live
- Conserver l'aperçu existant mais l'améliorer : remplacer les placeholders par des valeurs d'exemple en majuscules
- Exemples par type de champ : `text``VALEUR`, `number``123`, `select``OPTION`, `boolean``OUI`, `date``2026-01-01`
### Comportement
- **Insert au curseur** : quand l'utilisateur clique un chip, le placeholder est inséré à `selectionStart` de l'input, pas à la fin
- **Formule vide** : si la formule est vide, pas de référence auto (comportement actuel conservé)
- **Readonly** : les chips sont désactivés en mode readonly (comme l'input)
- **Pas de custom fields** : si aucun champ n'est défini dans la structure, la section reste visible mais les chips sont remplacés par un message informatif. L'utilisateur peut quand même taper une formule manuellement (cas edge)
### Format de sortie (inchangé)
```typescript
{
referenceFormula: "SNU {serie}-{diametre}/{type}" | null,
requiredFieldsForReference: ["serie", "diametre", "type"] | null // auto-calculé
}
```
### Pas de changement
- Backend (`ReferenceAutoGenerator`, `ReferenceAutoSubscriber`, entités) : aucun changement
- Format de stockage de la formule : identique (`{placeholder}` strings)
- API : identique

View File

@@ -0,0 +1,82 @@
# Références Fournisseur par Item — Design Spec
**Date :** 2026-03-31
**Statut :** Validé
## Contexte
Chaque entité (Machine, Pièce, Composant, Produit) a un champ `reference` générique et une relation ManyToMany avec `Constructeur`. Il n'existe aucun moyen de stocker une référence spécifique par fournisseur — si un item est vendu par 3 fournisseurs avec 3 références différentes, on ne peut en stocker qu'une seule.
## Objectif
Permettre de stocker une référence fournisseur (`supplierReference`) par couple (item, constructeur). Le champ `reference` existant reste inchangé comme référence interne. Le champ `supplierPrice` sur Product reste inchangé.
## Design
### Approche retenue : conversion ManyToMany → entités pivot
Remplacer les 4 tables de jointure simples (`_MachineConstructeurs`, `_PieceConstructeurs`, `_ComposantConstructeurs`, `_ProductConstructeurs`) par de vraies entités Doctrine Link, suivant le pattern existant (`MachinePieceLink`, `MachineComponentLink`, etc.).
### Nouvelles entités
| Entité | Table | FK item | FK constructeur | Champs extra |
|--------|-------|---------|-----------------|--------------|
| `MachineConstructeurLink` | `machine_constructeur_links` | `machineId``Machine` | `constructeurId``Constructeur` | `supplierReference` (string 255, nullable) |
| `PieceConstructeurLink` | `piece_constructeur_links` | `pieceId``Piece` | `constructeurId``Constructeur` | `supplierReference` (string 255, nullable) |
| `ComposantConstructeurLink` | `composant_constructeur_links` | `composantId``Composant` | `constructeurId``Constructeur` | `supplierReference` (string 255, nullable) |
| `ProductConstructeurLink` | `product_constructeur_links` | `productId``Product` | `constructeurId``Constructeur` | `supplierReference` (string 255, nullable) |
### Structure de chaque entité
Chaque entité suit le pattern `MachinePieceLink` :
- `CuidEntityTrait` pour l'ID (string, 36 chars)
- `#[ORM\HasLifecycleCallbacks]` avec `createdAt` / `updatedAt`
- Contrainte unique sur `(item_id, constructeur_id)` via `#[ORM\UniqueConstraint]`
- `#[ApiResource]` avec opérations CRUD complètes
- Sécurité : `ROLE_VIEWER` pour lecture, `ROLE_GESTIONNAIRE` pour écriture
- `ManyToOne` vers l'item (onDelete CASCADE)
- `ManyToOne` vers `Constructeur` (onDelete CASCADE)
- Champ `supplierReference` (string 255, nullable)
### Modifications sur les entités existantes
#### Machine, Pièce, Composant, Produit
- Supprimer la propriété `ManyToMany` `constructeurs` et ses getters/setters/add/remove
- Ajouter une propriété `OneToMany` `constructeurLinks` vers le Link correspondant
- Getter `getConstructeurLinks(): Collection`
#### Constructeur
- Supprimer les 4 propriétés `ManyToMany` (`machines`, `composants`, `pieces`, `products`) et leurs getters/setters
- Ajouter 4 propriétés `OneToMany` vers les Links correspondants
### Migration SQL
1. Créer les 4 nouvelles tables avec colonnes `id`, `machineId`/`pieceId`/etc., `constructeurId`, `supplierReference`, `createdAt`, `updatedAt`
2. Ajouter les contraintes uniques
3. Migrer les données des anciennes tables de jointure vers les nouvelles (génération CUID pour chaque ligne, `supplierReference` = NULL)
4. Supprimer les anciennes tables de jointure (`_MachineConstructeurs`, `_PieceConstructeurs`, `_ComposantConstructeurs`, `_ProductConstructeurs`)
### API
Endpoints API Platform auto-générés pour chaque Link :
- `GET /api/machine_constructeur_links` — liste (filtrable par machine, constructeur)
- `GET /api/machine_constructeur_links/{id}` — détail
- `POST /api/machine_constructeur_links` — créer un lien avec référence
- `PATCH /api/machine_constructeur_links/{id}` — modifier la référence
- `DELETE /api/machine_constructeur_links/{id}` — supprimer le lien
Idem pour les 3 autres types.
### Frontend
Les pages détail/édition qui affichent les constructeurs devront être adaptées pour :
- Afficher la `supplierReference` à côté de chaque constructeur
- Permettre l'édition de la référence fournisseur lors de l'ajout/modification d'un constructeur
- Utiliser les endpoints `*ConstructeurLink` au lieu de la collection `constructeurs`
### Hors périmètre
- Migration de `supplierPrice` de Product vers le Link (explicitement exclu)
- Modification du champ `reference` existant sur les entités
- Référence auto (`referenceAuto`) sur Pièce/Composant — non impactée

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Scaffold missing composant slots for existing composants that have
* a typeComposant with skeleton requirements but no corresponding slots.
*/
final class Version20260323100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Scaffold missing composant slots from skeleton requirements for existing composants';
}
public function up(Schema $schema): void
{
// Piece slots
$this->addSql(<<<'SQL'
INSERT INTO composant_piece_slots (id, "composantid", "typepieceid", quantity, position, "createdat", "updatedat")
SELECT
'cl' || substr(md5(random()::text || clock_timestamp()::text || spr.id), 1, 24),
c.id,
spr."typepieceid",
1,
spr.position,
NOW(),
NOW()
FROM composants c
JOIN skeleton_piece_requirements spr ON spr."modeltypeid" = c."typecomposantid"
WHERE c."typecomposantid" IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM composant_piece_slots cps
WHERE cps."composantid" = c.id AND cps."typepieceid" = spr."typepieceid"
)
SQL);
// Product slots
$this->addSql(<<<'SQL'
INSERT INTO composant_product_slots (id, "composantid", "typeproductid", "familycode", position, "createdat", "updatedat")
SELECT
'cl' || substr(md5(random()::text || clock_timestamp()::text || spr.id), 1, 24),
c.id,
spr."typeproductid",
spr."familycode",
spr.position,
NOW(),
NOW()
FROM composants c
JOIN skeleton_product_requirements spr ON spr."modeltypeid" = c."typecomposantid"
WHERE c."typecomposantid" IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM composant_product_slots cps
WHERE cps."composantid" = c.id AND cps."typeproductid" = spr."typeproductid"
)
SQL);
// Subcomponent slots
$this->addSql(<<<'SQL'
INSERT INTO composant_subcomponent_slots (id, "composantid", alias, "familycode", "typecomposantid", position, "createdat", "updatedat")
SELECT
'cl' || substr(md5(random()::text || clock_timestamp()::text || spr.id), 1, 24),
c.id,
spr.alias,
spr."familycode",
spr."typecomposantid",
spr.position,
NOW(),
NOW()
FROM composants c
JOIN skeleton_subcomponent_requirements spr ON spr."modeltypeid" = c."typecomposantid"
WHERE c."typecomposantid" IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM composant_subcomponent_slots css
WHERE css."composantid" = c.id
AND COALESCE(css."typecomposantid", '') = COALESCE(spr."typecomposantid", '')
AND COALESCE(css.alias, '') = COALESCE(spr.alias, '')
)
SQL);
}
public function down(Schema $schema): void
{
// No-op: slots created by this migration are valid data
}
}

View File

@@ -0,0 +1,31 @@
<?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 Version20260323141052 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add type column to documents table and classify existing documents by mimeType';
}
public function up(Schema $schema): void
{
$this->addSql("DO \$\$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'documents' AND column_name = 'type') THEN ALTER TABLE documents ADD COLUMN type VARCHAR(20) NOT NULL DEFAULT 'documentation'; END IF; END \$\$");
$this->addSql("UPDATE documents SET type = 'photo' WHERE mimetype LIKE 'image/%'");
$this->addSql("UPDATE documents SET type = 'autre' WHERE type = 'documentation' AND mimetype NOT LIKE 'application/pdf' AND mimetype NOT LIKE 'image/%'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE documents DROP COLUMN IF EXISTS type');
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260323160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add comment_id FK on documents table';
}
public function up(Schema $schema): void
{
$this->addSql("DO \$\$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'documents' AND column_name = 'comment_id') THEN ALTER TABLE documents ADD COLUMN comment_id VARCHAR(36) DEFAULT NULL; END IF; END \$\$");
$this->addSql("DO \$\$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_documents_comment') THEN ALTER TABLE documents ADD CONSTRAINT fk_documents_comment FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE; END IF; END \$\$");
$this->addSql('CREATE INDEX IF NOT EXISTS idx_documents_comment_id ON documents(comment_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_comment');
$this->addSql('DROP INDEX IF EXISTS idx_documents_comment_id');
$this->addSql('ALTER TABLE documents DROP COLUMN IF EXISTS comment_id');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260325214500 extends AbstractMigration
{
public function getDescription(): string
{
return 'Remove unique constraint on composants.name (uniqueness on reference is now enforced at application level via UniqueEntity)';
}
public function up(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS uniq_f95a31995e237e06');
}
public function down(Schema $schema): void
{
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_f95a31995e237e06 ON composants (name)');
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260326100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add version column to audit_logs and machines tables';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS version INT DEFAULT NULL');
$this->addSql('ALTER TABLE machines ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
$this->addSql('CREATE INDEX IF NOT EXISTS idx_audit_entity_version ON audit_logs (entitytype, entityid, version)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS idx_audit_entity_version');
$this->addSql('ALTER TABLE audit_logs DROP COLUMN IF EXISTS version');
$this->addSql('ALTER TABLE machines DROP COLUMN IF EXISTS version');
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260326120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add referenceFormula and requiredFieldsForReference to model_types, referenceAuto to pieces';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS referenceformula TEXT DEFAULT NULL');
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS requiredfieldsforreference JSON DEFAULT NULL');
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS referenceauto VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS referenceauto');
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS requiredfieldsforreference');
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS referenceformula');
}
}

View File

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

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260331121257 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create ConstructeurLink tables, migrate data from old join tables, drop old join tables';
}
public function up(Schema $schema): void
{
// Create new link tables
$this->addSql('CREATE TABLE IF NOT EXISTS composant_constructeur_links (id VARCHAR(36) NOT NULL, supplierReference VARCHAR(255) DEFAULT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updatedAt TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, composantId VARCHAR(36) NOT NULL, constructeurId VARCHAR(36) NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_139F3E3A345EE564 ON composant_constructeur_links (composantId)');
$this->addSql('CREATE INDEX IDX_139F3E3A70AF5AF0 ON composant_constructeur_links (constructeurId)');
$this->addSql('CREATE UNIQUE INDEX uniq_composant_constructeur ON composant_constructeur_links (composantid, constructeurid)');
$this->addSql('CREATE TABLE IF NOT EXISTS machine_constructeur_links (id VARCHAR(36) NOT NULL, supplierReference VARCHAR(255) DEFAULT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updatedAt TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, machineId VARCHAR(36) NOT NULL, constructeurId VARCHAR(36) NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_665B2620633EC4FD ON machine_constructeur_links (machineId)');
$this->addSql('CREATE INDEX IDX_665B262070AF5AF0 ON machine_constructeur_links (constructeurId)');
$this->addSql('CREATE UNIQUE INDEX uniq_machine_constructeur ON machine_constructeur_links (machineid, constructeurid)');
$this->addSql('CREATE TABLE IF NOT EXISTS piece_constructeur_links (id VARCHAR(36) NOT NULL, supplierReference VARCHAR(255) DEFAULT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updatedAt TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, pieceId VARCHAR(36) NOT NULL, constructeurId VARCHAR(36) NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_E664DAEC3C6A9D1 ON piece_constructeur_links (pieceId)');
$this->addSql('CREATE INDEX IDX_E664DAEC70AF5AF0 ON piece_constructeur_links (constructeurId)');
$this->addSql('CREATE UNIQUE INDEX uniq_piece_constructeur ON piece_constructeur_links (pieceid, constructeurid)');
$this->addSql('CREATE TABLE IF NOT EXISTS product_constructeur_links (id VARCHAR(36) NOT NULL, supplierReference VARCHAR(255) DEFAULT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updatedAt TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, productId VARCHAR(36) NOT NULL, constructeurId VARCHAR(36) NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_E3D850B536799605 ON product_constructeur_links (productId)');
$this->addSql('CREATE INDEX IDX_E3D850B570AF5AF0 ON product_constructeur_links (constructeurId)');
$this->addSql('CREATE UNIQUE INDEX uniq_product_constructeur ON product_constructeur_links (productid, constructeurid)');
// Foreign keys
$this->addSql('ALTER TABLE composant_constructeur_links ADD CONSTRAINT FK_139F3E3A345EE564 FOREIGN KEY (composantId) REFERENCES composants (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE composant_constructeur_links ADD CONSTRAINT FK_139F3E3A70AF5AF0 FOREIGN KEY (constructeurId) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE machine_constructeur_links ADD CONSTRAINT FK_665B2620633EC4FD FOREIGN KEY (machineId) REFERENCES machines (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE machine_constructeur_links ADD CONSTRAINT FK_665B262070AF5AF0 FOREIGN KEY (constructeurId) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE piece_constructeur_links ADD CONSTRAINT FK_E664DAEC3C6A9D1 FOREIGN KEY (pieceId) REFERENCES pieces (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE piece_constructeur_links ADD CONSTRAINT FK_E664DAEC70AF5AF0 FOREIGN KEY (constructeurId) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE product_constructeur_links ADD CONSTRAINT FK_E3D850B536799605 FOREIGN KEY (productId) REFERENCES products (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('ALTER TABLE product_constructeur_links ADD CONSTRAINT FK_E3D850B570AF5AF0 FOREIGN KEY (constructeurId) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE');
// Data migration: copy from old join tables to new link tables
$this->addSql("INSERT INTO machine_constructeur_links (id, machineid, constructeurid, supplierreference, createdat, updatedat) SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), a, b, NULL, NOW(), NOW() FROM \"_machineconstructeurs\"");
$this->addSql("INSERT INTO piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), a, b, NULL, NOW(), NOW() FROM \"_piececonstructeurs\"");
$this->addSql("INSERT INTO composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), a, b, NULL, NOW(), NOW() FROM \"_composantconstructeurs\"");
$this->addSql("INSERT INTO product_constructeur_links (id, productid, constructeurid, supplierreference, createdat, updatedat) SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), a, b, NULL, NOW(), NOW() FROM \"_productconstructeurs\"");
// Drop old join tables
$this->addSql('ALTER TABLE _composantconstructeurs DROP CONSTRAINT IF EXISTS fk_607601254ad0cf31');
$this->addSql('ALTER TABLE _composantconstructeurs DROP CONSTRAINT IF EXISTS fk_60760125d3d99e8b');
$this->addSql('ALTER TABLE _machineconstructeurs DROP CONSTRAINT IF EXISTS fk_e6a040cc4ad0cf31');
$this->addSql('ALTER TABLE _machineconstructeurs DROP CONSTRAINT IF EXISTS fk_e6a040ccd3d99e8b');
$this->addSql('ALTER TABLE _piececonstructeurs DROP CONSTRAINT IF EXISTS fk_e94732e54ad0cf31');
$this->addSql('ALTER TABLE _piececonstructeurs DROP CONSTRAINT IF EXISTS fk_e94732e5d3d99e8b');
$this->addSql('ALTER TABLE _productconstructeurs DROP CONSTRAINT IF EXISTS fk_cf7403fc4ad0cf31');
$this->addSql('ALTER TABLE _productconstructeurs DROP CONSTRAINT IF EXISTS fk_cf7403fcd3d99e8b');
$this->addSql('DROP TABLE IF EXISTS _composantconstructeurs');
$this->addSql('DROP TABLE IF EXISTS _machineconstructeurs');
$this->addSql('DROP TABLE IF EXISTS _piececonstructeurs');
$this->addSql('DROP TABLE IF EXISTS _productconstructeurs');
}
public function down(Schema $schema): void
{
// Recreate old join tables
$this->addSql('CREATE TABLE _composantconstructeurs (a VARCHAR(36) NOT NULL, b VARCHAR(36) NOT NULL, PRIMARY KEY (a, b))');
$this->addSql('CREATE INDEX "_ComposantConstructeurs_B_index" ON _composantconstructeurs (b)');
$this->addSql('CREATE INDEX IDX_5B97D813E8B7BE43 ON _composantconstructeurs (a)');
$this->addSql('CREATE TABLE _machineconstructeurs (a VARCHAR(36) NOT NULL, b VARCHAR(36) NOT NULL, PRIMARY KEY (a, b))');
$this->addSql('CREATE INDEX "_MachineConstructeurs_B_index" ON _machineconstructeurs (b)');
$this->addSql('CREATE INDEX IDX_4F225B32E8B7BE43 ON _machineconstructeurs (a)');
$this->addSql('CREATE TABLE _piececonstructeurs (a VARCHAR(36) NOT NULL, b VARCHAR(36) NOT NULL, PRIMARY KEY (a, b))');
$this->addSql('CREATE INDEX "_PieceConstructeurs_B_index" ON _piececonstructeurs (b)');
$this->addSql('CREATE INDEX IDX_77FC120E8B7BE43 ON _piececonstructeurs (a)');
$this->addSql('CREATE TABLE _productconstructeurs (a VARCHAR(36) NOT NULL, b VARCHAR(36) NOT NULL, PRIMARY KEY (a, b))');
$this->addSql('CREATE INDEX "_ProductConstructeurs_B_index" ON _productconstructeurs (b)');
$this->addSql('CREATE INDEX IDX_66F61802E8B7BE43 ON _productconstructeurs (a)');
$this->addSql('ALTER TABLE _composantconstructeurs ADD CONSTRAINT fk_607601254ad0cf31 FOREIGN KEY (b) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _composantconstructeurs ADD CONSTRAINT fk_60760125d3d99e8b FOREIGN KEY (a) REFERENCES composants (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _machineconstructeurs ADD CONSTRAINT fk_e6a040cc4ad0cf31 FOREIGN KEY (b) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _machineconstructeurs ADD CONSTRAINT fk_e6a040ccd3d99e8b FOREIGN KEY (a) REFERENCES machines (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _piececonstructeurs ADD CONSTRAINT fk_e94732e54ad0cf31 FOREIGN KEY (b) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _piececonstructeurs ADD CONSTRAINT fk_e94732e5d3d99e8b FOREIGN KEY (a) REFERENCES pieces (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _productconstructeurs ADD CONSTRAINT fk_cf7403fc4ad0cf31 FOREIGN KEY (b) REFERENCES constructeurs (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE _productconstructeurs ADD CONSTRAINT fk_cf7403fcd3d99e8b FOREIGN KEY (a) REFERENCES products (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
// Data migration: copy back from new link tables to old join tables
$this->addSql('INSERT INTO _machineconstructeurs (a, b) SELECT machineid, constructeurid FROM machine_constructeur_links');
$this->addSql('INSERT INTO _piececonstructeurs (a, b) SELECT pieceid, constructeurid FROM piece_constructeur_links');
$this->addSql('INSERT INTO _composantconstructeurs (a, b) SELECT composantid, constructeurid FROM composant_constructeur_links');
$this->addSql('INSERT INTO _productconstructeurs (a, b) SELECT productid, constructeurid FROM product_constructeur_links');
// Drop new link tables
$this->addSql('ALTER TABLE composant_constructeur_links DROP CONSTRAINT FK_139F3E3A345EE564');
$this->addSql('ALTER TABLE composant_constructeur_links DROP CONSTRAINT FK_139F3E3A70AF5AF0');
$this->addSql('ALTER TABLE machine_constructeur_links DROP CONSTRAINT FK_665B2620633EC4FD');
$this->addSql('ALTER TABLE machine_constructeur_links DROP CONSTRAINT FK_665B262070AF5AF0');
$this->addSql('ALTER TABLE piece_constructeur_links DROP CONSTRAINT FK_E664DAEC3C6A9D1');
$this->addSql('ALTER TABLE piece_constructeur_links DROP CONSTRAINT FK_E664DAEC70AF5AF0');
$this->addSql('ALTER TABLE product_constructeur_links DROP CONSTRAINT FK_E3D850B536799605');
$this->addSql('ALTER TABLE product_constructeur_links DROP CONSTRAINT FK_E3D850B570AF5AF0');
$this->addSql('DROP TABLE composant_constructeur_links');
$this->addSql('DROP TABLE machine_constructeur_links');
$this->addSql('DROP TABLE piece_constructeur_links');
$this->addSql('DROP TABLE product_constructeur_links');
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/../vendor/autoload.php';
use Doctrine\DBAL\DriverManager;
$conn = DriverManager::getConnection([
'driver' => 'pdo_pgsql',
'host' => 'localhost',
'port' => 5432,
'dbname' => 'inventory',
'user' => 'ferme_user',
'password' => 'fermerecette',
]);
echo "--- Audit logs with customField deletions (to:null) ---\n";
$rows = $conn->fetchAllAssociative("
SELECT al.entityid, al.entitytype, al.diff::text as diff, al.createdat
FROM audit_logs al
WHERE al.diff::text LIKE '%customField%'
AND al.diff::text LIKE '%\"to\":null%'
ORDER BY al.createdat DESC
LIMIT 20
");
echo sprintf("Found %d entries\n\n", count($rows));
foreach ($rows as $r) {
echo sprintf("[%s] %s %s: %s\n", $r['createdat'], $r['entitytype'], $r['entityid'], substr($r['diff'], 0, 120));
}
echo "\n--- Orphaned CFValues (pointing to CFs with no ModelType) ---\n";
$rows = $conn->fetchAllAssociative("
SELECT COUNT(*) as cnt,
CASE WHEN cfv.pieceid IS NOT NULL THEN 'piece'
WHEN cfv.composantid IS NOT NULL THEN 'composant'
WHEN cfv.productid IS NOT NULL THEN 'product'
ELSE 'unknown' END as entity_type
FROM custom_field_values cfv
JOIN custom_fields cf ON cf.id = cfv.customfieldid
WHERE cf.typecomposantid IS NULL AND cf.typepieceid IS NULL AND cf.typeproductid IS NULL
GROUP BY entity_type
");
foreach ($rows as $r) {
echo sprintf(" %s: %d orphaned values\n", $r['entity_type'], $r['cnt']);
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/../vendor/autoload.php';
use Doctrine\DBAL\DriverManager;
$conn = DriverManager::getConnection([
'driver' => 'pdo_pgsql',
'host' => 'localhost',
'port' => 5432,
'dbname' => 'inventory',
'user' => 'ferme_user',
'password' => 'fermerecette',
]);
echo "--- ModelTypes with orphaned piece values (CFs lost) ---\n\n";
$rows = $conn->fetchAllAssociative("
SELECT mt.id, mt.name, mt.category,
cf_orphan.name as lost_field,
COUNT(*) as affected_pieces,
COUNT(*) FILTER (WHERE cfv.value != '' AND cfv.value IS NOT NULL) as with_data
FROM custom_field_values cfv
JOIN custom_fields cf_orphan ON cf_orphan.id = cfv.customfieldid
JOIN pieces p ON p.id = cfv.pieceid
JOIN model_types mt ON mt.id = p.typepieceid
WHERE cf_orphan.typecomposantid IS NULL
AND cf_orphan.typepieceid IS NULL
AND cf_orphan.typeproductid IS NULL
GROUP BY mt.id, mt.name, mt.category, cf_orphan.name
ORDER BY mt.name, cf_orphan.name
");
foreach ($rows as $r) {
$status = $r['with_data'] > 0 ? 'HAS DATA' : 'empty';
echo sprintf(" ModelType '%s' | field '%s' | %d pieces (%d with data) [%s]\n",
$r['name'], $r['lost_field'], $r['affected_pieces'], $r['with_data'], $status);
}
echo sprintf("\nTotal: %d ModelType/field combinations\n", count($rows));
// Check if these fields exist on the current ModelType
echo "\n--- Current CFs on these ModelTypes ---\n\n";
$mtIds = array_unique(array_column($rows, 'id'));
foreach ($mtIds as $mtId) {
$mtName = $conn->fetchOne("SELECT name FROM model_types WHERE id = ?", [$mtId]);
$currentCfs = $conn->fetchAllAssociative(
"SELECT name FROM custom_fields WHERE typepieceid = ? ORDER BY orderindex",
[$mtId]
);
$cfNames = array_column($currentCfs, 'name');
echo sprintf(" '%s': %s\n", $mtName, $cfNames ? implode(', ', $cfNames) : '(aucun CF)');
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/../vendor/autoload.php';
use Doctrine\DBAL\DriverManager;
$conn = DriverManager::getConnection([
'driver' => 'pdo_pgsql',
'host' => 'localhost',
'port' => 5432,
'dbname' => 'inventory',
'user' => 'ferme_user',
'password' => 'fermerecette',
]);
// Show a sample of orphaned CFValues for pieces
echo "--- Sample orphaned piece CFValues ---\n";
$rows = $conn->fetchAllAssociative("
SELECT cfv.id as cfv_id, cfv.value, cfv.pieceid,
cf.id as cf_id, cf.name as cf_name,
cf.typecomposantid, cf.typepieceid, cf.typeproductid,
p.name as piece_name, p.typepieceid as piece_modeltype
FROM custom_field_values cfv
JOIN custom_fields cf ON cf.id = cfv.customfieldid
JOIN pieces p ON p.id = cfv.pieceid
WHERE cfv.pieceid IS NOT NULL
AND cf.typepieceid IS NULL
ORDER BY p.name
LIMIT 10
");
echo sprintf("Found %d (limited to 10)\n\n", count($rows));
foreach ($rows as $r) {
echo sprintf(" Piece '%s' | field '%s' = '%s' | CF FK: composant=%s piece=%s product=%s\n",
$r['piece_name'], $r['cf_name'], $r['value'],
$r['typecomposantid'] ?? 'NULL',
$r['typepieceid'] ?? 'NULL',
$r['typeproductid'] ?? 'NULL'
);
}
// Show a sample of orphaned CFValues for composants
echo "\n--- Sample orphaned composant CFValues ---\n";
$rows = $conn->fetchAllAssociative("
SELECT cfv.id as cfv_id, cfv.value, cfv.composantid,
cf.id as cf_id, cf.name as cf_name,
cf.typecomposantid, cf.typepieceid, cf.typeproductid,
c.name as composant_name, c.typecomposantid as composant_modeltype
FROM custom_field_values cfv
JOIN custom_fields cf ON cf.id = cfv.customfieldid
JOIN composants c ON c.id = cfv.composantid
WHERE cfv.composantid IS NOT NULL
AND cf.typecomposantid IS NULL
ORDER BY c.name
LIMIT 10
");
echo sprintf("Found %d (limited to 10)\n\n", count($rows));
foreach ($rows as $r) {
echo sprintf(" Composant '%s' | field '%s' = '%s' | CF FK: composant=%s piece=%s product=%s\n",
$r['composant_name'], $r['cf_name'], $r['value'],
$r['typecomposantid'] ?? 'NULL',
$r['typepieceid'] ?? 'NULL',
$r['typeproductid'] ?? 'NULL'
);
}
// Check: are there CFs with ONLY typepieceid NULL but other FKs set?
echo "\n--- Orphaned CF FK patterns ---\n";
$rows = $conn->fetchAllAssociative("
SELECT
CASE WHEN typecomposantid IS NULL THEN 'NULL' ELSE 'SET' END as composant_fk,
CASE WHEN typepieceid IS NULL THEN 'NULL' ELSE 'SET' END as piece_fk,
CASE WHEN typeproductid IS NULL THEN 'NULL' ELSE 'SET' END as product_fk,
COUNT(*) as cnt
FROM custom_fields
GROUP BY composant_fk, piece_fk, product_fk
ORDER BY cnt DESC
");
foreach ($rows as $r) {
echo sprintf(" composant=%s piece=%s product=%s : %d CFs\n",
$r['composant_fk'], $r['piece_fk'], $r['product_fk'], $r['cnt']);
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/../vendor/autoload.php';
use Doctrine\DBAL\DriverManager;
$conn = DriverManager::getConnection([
'driver' => 'pdo_pgsql',
'host' => 'localhost',
'port' => 5432,
'dbname' => 'inventory',
'user' => 'ferme_user',
'password' => 'fermerecette',
]);
echo "--- Piece 'Arbre du palier pied E1' ---\n";
$rows = $conn->fetchAllAssociative("SELECT p.name, cfv.value, cf.name as field_name FROM pieces p JOIN custom_field_values cfv ON cfv.pieceid = p.id JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE p.id = 'cl3d978dd4b071daff8fb185f7' ORDER BY cf.orderindex");
foreach ($rows as $r) {
echo sprintf(" %s: '%s'\n", $r['field_name'], $r['value']);
}
echo "\n--- Composant 'Cage écureuil pied E8' ---\n";
$rows = $conn->fetchAllAssociative("SELECT c.name, cfv.value, cf.name as field_name FROM composants c JOIN custom_field_values cfv ON cfv.composantid = c.id JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE c.id = 'cl5b5e336095de8d4ece81b2dc' ORDER BY cf.orderindex");
foreach ($rows as $r) {
echo sprintf(" %s: '%s'\n", $r['field_name'], $r['value']);
}
echo "\n--- Count empty piece values (ModelType Arbre) ---\n";
$count = $conn->fetchOne("SELECT COUNT(*) FROM pieces p JOIN custom_field_values cfv ON cfv.pieceid = p.id WHERE p.typepieceid = 'cmgujpyjf002q4705j6hv1nkk' AND (cfv.value = '' OR cfv.value IS NULL)");
echo sprintf(" Empty values: %d\n", $count);
echo "\n--- Count orphaned CustomField definitions ---\n";
$count = $conn->fetchOne('SELECT COUNT(*) FROM custom_fields WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL');
echo sprintf(" Orphaned CFs: %d\n", $count);

199
scripts/fix-prod-all.php Normal file
View File

@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
/**
* Combined fix script for prod:
* 1. Migrate orphaned CFValues to current CFs (by name match)
* 2. Restore deleted composant values from audit logs
* 3. Clean up orphaned CF definitions
*
* Usage: php scripts/fix-prod-all.php [--dry-run]
*/
require_once __DIR__.'/../vendor/autoload.php';
use Doctrine\DBAL\DriverManager;
$dryRun = in_array('--dry-run', $argv, true);
$conn = DriverManager::getConnection([
'driver' => 'pdo_pgsql',
'host' => 'localhost',
'port' => 5432,
'dbname' => 'inventory',
'user' => 'ferme_user',
'password' => 'fermerecette',
]);
echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n";
$migratedCount = 0;
$restoredCount = 0;
$deletedOrphanedCfv = 0;
$deletedOrphanedCf = 0;
$skippedCount = 0;
// ============================================================
// PART 1: Migrate orphaned CFValues to current CFs
// ============================================================
echo "--- PART 1: Migrate orphaned CFValues ---\n\n";
$entityTypes = [
['label' => 'piece', 'entityTable' => 'pieces', 'cfvFk' => 'pieceid', 'modelTypeFk' => 'typepieceid', 'cfModelTypeFk' => 'typepieceid'],
['label' => 'composant', 'entityTable' => 'composants', 'cfvFk' => 'composantid', 'modelTypeFk' => 'typecomposantid', 'cfModelTypeFk' => 'typecomposantid'],
['label' => 'product', 'entityTable' => 'products', 'cfvFk' => 'productid', 'modelTypeFk' => 'typeproductid', 'cfModelTypeFk' => 'typeproductid'],
];
foreach ($entityTypes as $et) {
// Find orphaned CFValues: the CF has ALL 3 FKs NULL
$orphanedValues = $conn->fetchAllAssociative("
SELECT cfv.id as cfv_id, cfv.value, cfv.{$et['cfvFk']} as entity_id,
cf_old.id as old_cf_id, cf_old.name as field_name,
e.name as entity_name, e.{$et['modelTypeFk']} as model_type_id
FROM custom_field_values cfv
JOIN custom_fields cf_old ON cf_old.id = cfv.customfieldid
JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']}
WHERE cfv.{$et['cfvFk']} IS NOT NULL
AND cf_old.typecomposantid IS NULL
AND cf_old.typepieceid IS NULL
AND cf_old.typeproductid IS NULL
ORDER BY e.name, cf_old.name
");
echo sprintf(" %ss: %d orphaned values\n", $et['label'], count($orphanedValues));
foreach ($orphanedValues as $ov) {
if (!$ov['model_type_id']) {
++$skippedCount;
continue;
}
$currentCf = $conn->fetchAssociative(
"SELECT id FROM custom_fields WHERE {$et['cfModelTypeFk']} = ? AND name = ? LIMIT 1",
[$ov['model_type_id'], $ov['field_name']]
);
if (!$currentCf) {
// No matching CF on current ModelType — skip but keep value
++$skippedCount;
continue;
}
$existingValue = $conn->fetchAssociative(
"SELECT id, value FROM custom_field_values WHERE {$et['cfvFk']} = ? AND customfieldid = ?",
[$ov['entity_id'], $currentCf['id']]
);
if ($existingValue) {
if (('' === $existingValue['value'] || null === $existingValue['value']) && '' !== $ov['value'] && null !== $ov['value']) {
echo sprintf(" MIGRATE: %s '%s' field '%s' = '%s'\n", $et['label'], $ov['entity_name'], $ov['field_name'], $ov['value']);
if (!$dryRun) {
$conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$ov['value'], $existingValue['id']]);
}
++$migratedCount;
}
if (!$dryRun) {
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]);
}
++$deletedOrphanedCfv;
} else {
echo sprintf(" REASSIGN: %s '%s' field '%s' = '%s'\n", $et['label'], $ov['entity_name'], $ov['field_name'], $ov['value']);
if (!$dryRun) {
$conn->executeStatement('UPDATE custom_field_values SET customfieldid = ? WHERE id = ?', [$currentCf['id'], $ov['cfv_id']]);
}
++$migratedCount;
}
}
}
// ============================================================
// PART 2: Restore composant values from audit logs
// ============================================================
echo "\n--- PART 2: Restore values from audit logs ---\n\n";
$deletionLogs = $conn->fetchAllAssociative("
SELECT al.entityid, al.entitytype, al.diff::text as diff
FROM audit_logs al
WHERE al.diff::text LIKE '%customField%'
AND al.diff::text LIKE '%\"to\":null%'
ORDER BY al.createdat DESC
");
echo sprintf(" Found %d audit entries with deleted values\n", count($deletionLogs));
foreach ($deletionLogs as $log) {
$diff = json_decode($log['diff'], true);
$entityType = $log['entitytype'];
$cfvFk = match ($entityType) {
'piece' => 'pieceid',
'composant' => 'composantid',
'product' => 'productid',
default => null,
};
if (!$cfvFk) {
continue;
}
foreach ($diff as $key => $change) {
if (!str_starts_with($key, 'customField:')) {
continue;
}
if (null !== $change['to']) {
continue;
}
$oldValue = $change['from'];
if (null === $oldValue || '' === $oldValue) {
continue;
}
$fieldName = substr($key, strlen('customField:'));
$cfv = $conn->fetchAssociative(
"SELECT cfv.id, cfv.value FROM custom_field_values cfv JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE cfv.{$cfvFk} = ? AND cf.name = ?",
[$log['entityid'], $fieldName]
);
if (!$cfv) {
continue;
}
if ('' !== $cfv['value'] && null !== $cfv['value']) {
continue;
}
echo sprintf(" RESTORE: %s %s field '%s' = '%s'\n", $entityType, $log['entityid'], $fieldName, $oldValue);
if (!$dryRun) {
$conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$oldValue, $cfv['id']]);
}
++$restoredCount;
}
}
// ============================================================
// PART 3: Clean orphaned CF definitions
// ============================================================
echo "\n--- PART 3: Clean orphaned CF definitions ---\n\n";
$orphanedCfs = $conn->fetchAllAssociative('
SELECT cf.id FROM custom_fields cf
WHERE cf.typecomposantid IS NULL AND cf.typepieceid IS NULL AND cf.typeproductid IS NULL
AND NOT EXISTS (SELECT 1 FROM custom_field_values cfv WHERE cfv.customfieldid = cf.id)
');
echo sprintf(" %d orphaned CF definitions to delete\n", count($orphanedCfs));
foreach ($orphanedCfs as $cf) {
if (!$dryRun) {
$conn->executeStatement('DELETE FROM custom_fields WHERE id = ?', [$cf['id']]);
}
++$deletedOrphanedCf;
}
echo sprintf("\n=== SUMMARY ===\n");
echo sprintf("Values migrated/reassigned: %d\n", $migratedCount);
echo sprintf("Values restored from audit: %d\n", $restoredCount);
echo sprintf("Orphaned CFValues cleaned: %d\n", $deletedOrphanedCfv);
echo sprintf("Orphaned CF definitions deleted: %d\n", $deletedOrphanedCf);
echo sprintf("Skipped (no matching CF on ModelType): %d\n", $skippedCount);
echo "=== DONE ===\n";

View File

@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
/**
* Full prod fix:
* 1. Re-create missing CustomField definitions on ModelTypes (from orphaned CFs that still have values)
* 2. Migrate orphaned CFValues to the newly created CFs
* 3. Restore deleted values from audit logs
* 4. Clean up orphaned CFs with no remaining values
*
* Usage: php scripts/fix-prod-recreate-and-migrate.php [--dry-run]
*/
require_once __DIR__.'/../vendor/autoload.php';
use Doctrine\DBAL\DriverManager;
$dryRun = in_array('--dry-run', $argv, true);
$conn = DriverManager::getConnection([
'driver' => 'pdo_pgsql',
'host' => 'localhost',
'port' => 5432,
'dbname' => 'inventory',
'user' => 'ferme_user',
'password' => 'fermerecette',
]);
echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n";
$createdCfCount = 0;
$migratedCount = 0;
$restoredCount = 0;
$deletedOrphanedCfv = 0;
$deletedOrphanedCf = 0;
$entityTypes = [
['label' => 'piece', 'entityTable' => 'pieces', 'cfvFk' => 'pieceid', 'modelTypeFk' => 'typepieceid', 'cfFk' => 'typepieceid'],
['label' => 'composant', 'entityTable' => 'composants', 'cfvFk' => 'composantid', 'modelTypeFk' => 'typecomposantid', 'cfFk' => 'typecomposantid'],
['label' => 'product', 'entityTable' => 'products', 'cfvFk' => 'productid', 'modelTypeFk' => 'typeproductid', 'cfFk' => 'typeproductid'],
];
// ============================================================
// PART 1: Re-create missing CF definitions on ModelTypes
// ============================================================
echo "--- PART 1: Re-create missing CF definitions ---\n\n";
foreach ($entityTypes as $et) {
// Find distinct (ModelType, field name, type) from orphaned CFs that have values
$missingDefs = $conn->fetchAllAssociative("
SELECT e.{$et['modelTypeFk']} as model_type_id,
mt.name as model_type_name,
cf_orphan.name as field_name,
MIN(cf_orphan.type) as field_type,
BOOL_OR(COALESCE(cf_orphan.required, false)) as field_required,
MIN(cf_orphan.options::text) as field_options,
MIN(cf_orphan.defaultvalue) as field_default
FROM custom_field_values cfv
JOIN custom_fields cf_orphan ON cf_orphan.id = cfv.customfieldid
JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']}
JOIN model_types mt ON mt.id = e.{$et['modelTypeFk']}
WHERE cfv.{$et['cfvFk']} IS NOT NULL
AND cf_orphan.typecomposantid IS NULL
AND cf_orphan.typepieceid IS NULL
AND cf_orphan.typeproductid IS NULL
GROUP BY e.{$et['modelTypeFk']}, mt.name, cf_orphan.name
ORDER BY mt.name, cf_orphan.name
");
foreach ($missingDefs as $def) {
// Check if this CF already exists on the ModelType
$exists = $conn->fetchOne(
"SELECT COUNT(*) FROM custom_fields WHERE {$et['cfFk']} = ? AND name = ?",
[$def['model_type_id'], $def['field_name']]
);
if ($exists > 0) {
continue;
}
// Get next orderIndex
$maxOrder = $conn->fetchOne(
"SELECT COALESCE(MAX(orderindex), -1) FROM custom_fields WHERE {$et['cfFk']} = ?",
[$def['model_type_id']]
);
$nextOrder = ((int) $maxOrder) + 1;
// Generate CUID-like ID
$newId = 'cl' . bin2hex(random_bytes(12));
echo sprintf(" CREATE CF: ModelType '%s' (%s) + field '%s' (type=%s)\n",
$def['model_type_name'], $et['label'], $def['field_name'], $def['field_type']);
if (!$dryRun) {
$options = $def['field_options'];
if (null !== $options && 'null' === $options) {
$options = null;
}
$required = !empty($def['field_required']) && 'f' !== $def['field_required'];
$conn->executeStatement(
"INSERT INTO custom_fields (id, name, type, required, options, defaultvalue, orderindex, {$et['cfFk']})
VALUES (?, ?, ?, ?::boolean, ?::json, ?, ?, ?)",
[
$newId,
$def['field_name'],
$def['field_type'],
$required ? 'true' : 'false',
$options,
$def['field_default'],
$nextOrder,
$def['model_type_id'],
]
);
}
++$createdCfCount;
}
}
echo sprintf("\n Created %d CF definitions\n\n", $createdCfCount);
// ============================================================
// PART 2: Migrate orphaned CFValues to current CFs
// ============================================================
echo "--- PART 2: Migrate orphaned CFValues ---\n\n";
foreach ($entityTypes as $et) {
$orphanedValues = $conn->fetchAllAssociative("
SELECT cfv.id as cfv_id, cfv.value, cfv.{$et['cfvFk']} as entity_id,
cf_old.name as field_name,
e.name as entity_name, e.{$et['modelTypeFk']} as model_type_id
FROM custom_field_values cfv
JOIN custom_fields cf_old ON cf_old.id = cfv.customfieldid
JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']}
WHERE cfv.{$et['cfvFk']} IS NOT NULL
AND cf_old.typecomposantid IS NULL
AND cf_old.typepieceid IS NULL
AND cf_old.typeproductid IS NULL
ORDER BY e.name, cf_old.name
");
$migrated = 0;
$cleaned = 0;
foreach ($orphanedValues as $ov) {
if (!$ov['model_type_id']) {
continue;
}
$currentCf = $conn->fetchAssociative(
"SELECT id FROM custom_fields WHERE {$et['cfFk']} = ? AND name = ? LIMIT 1",
[$ov['model_type_id'], $ov['field_name']]
);
if (!$currentCf) {
continue;
}
$existingValue = $conn->fetchAssociative(
"SELECT id, value FROM custom_field_values WHERE {$et['cfvFk']} = ? AND customfieldid = ?",
[$ov['entity_id'], $currentCf['id']]
);
if ($existingValue) {
if (('' === $existingValue['value'] || null === $existingValue['value']) && '' !== $ov['value'] && null !== $ov['value']) {
if (!$dryRun) {
$conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$ov['value'], $existingValue['id']]);
}
++$migrated;
}
if (!$dryRun) {
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]);
}
++$cleaned;
} else {
if (!$dryRun) {
$conn->executeStatement('UPDATE custom_field_values SET customfieldid = ? WHERE id = ?', [$currentCf['id'], $ov['cfv_id']]);
}
++$migrated;
}
}
echo sprintf(" %ss: %d migrated, %d cleaned\n", $et['label'], $migrated, $cleaned);
$migratedCount += $migrated;
$deletedOrphanedCfv += $cleaned;
}
// ============================================================
// PART 3: Restore values from audit logs
// ============================================================
echo "\n--- PART 3: Restore values from audit logs ---\n\n";
$deletionLogs = $conn->fetchAllAssociative("
SELECT al.entityid, al.entitytype, al.diff::text as diff
FROM audit_logs al
WHERE al.diff::text LIKE '%customField%'
AND al.diff::text LIKE '%\"to\":null%'
ORDER BY al.createdat DESC
");
foreach ($deletionLogs as $log) {
$diff = json_decode($log['diff'], true);
$cfvFk = match ($log['entitytype']) {
'piece' => 'pieceid',
'composant' => 'composantid',
'product' => 'productid',
default => null,
};
if (!$cfvFk) {
continue;
}
foreach ($diff as $key => $change) {
if (!str_starts_with($key, 'customField:')) {
continue;
}
if (null !== $change['to'] || null === $change['from'] || '' === $change['from']) {
continue;
}
$fieldName = substr($key, strlen('customField:'));
$cfv = $conn->fetchAssociative(
"SELECT cfv.id, cfv.value FROM custom_field_values cfv JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE cfv.{$cfvFk} = ? AND cf.name = ?",
[$log['entityid'], $fieldName]
);
if (!$cfv || ('' !== $cfv['value'] && null !== $cfv['value'])) {
continue;
}
echo sprintf(" RESTORE: %s %s field '%s' = '%s'\n", $log['entitytype'], $log['entityid'], $fieldName, $change['from']);
if (!$dryRun) {
$conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$change['from'], $cfv['id']]);
}
++$restoredCount;
}
}
echo sprintf(" Restored: %d\n", $restoredCount);
// ============================================================
// PART 4: Clean orphaned CFs with no values
// ============================================================
echo "\n--- PART 4: Clean orphaned CF definitions ---\n\n";
$orphanedCfs = $conn->fetchAllAssociative('
SELECT id FROM custom_fields
WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL
AND NOT EXISTS (SELECT 1 FROM custom_field_values cfv WHERE cfv.customfieldid = id)
');
echo sprintf(" %d orphaned CFs to delete\n", count($orphanedCfs));
foreach ($orphanedCfs as $cf) {
if (!$dryRun) {
$conn->executeStatement('DELETE FROM custom_fields WHERE id = ?', [$cf['id']]);
}
++$deletedOrphanedCf;
}
echo sprintf("\n=== SUMMARY ===\n");
echo sprintf("CF definitions re-created: %d\n", $createdCfCount);
echo sprintf("Values migrated: %d\n", $migratedCount);
echo sprintf("Values restored from audit: %d\n", $restoredCount);
echo sprintf("Orphaned CFValues cleaned: %d\n", $deletedOrphanedCfv);
echo sprintf("Orphaned CFs deleted: %d\n", $deletedOrphanedCf);
echo "=== DONE ===\n";

View File

@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
/**
* Migrate CustomFieldValues from orphaned CustomField definitions to current ones.
*
* When SkeletonStructureService::updateCustomFields() runs without IDs from the frontend,
* it deletes old CustomField definitions and creates new ones. But the FK on custom_fields
* is SET NULL (not CASCADE), so old CFs become orphaned (all type FKs = NULL) and their
* CFValues still exist but point to the wrong CF.
*
* This script:
* 1. For each entity (composant, piece, product) with CFValues pointing to orphaned CFs,
* find the matching current CF by name on the entity's ModelType
* 2. Reassign the CFValue to the current CF
* 3. Delete orphaned CF definitions that no longer have any values
*
* Usage: php scripts/migrate-orphaned-custom-fields.php [--dry-run]
*/
require_once __DIR__.'/../vendor/autoload.php';
use Doctrine\DBAL\DriverManager;
$dryRun = in_array('--dry-run', $argv, true);
$env = getenv('APP_ENV') ?: 'local';
$conn = DriverManager::getConnection(match ($env) {
'prod' => [
'driver' => 'pdo_pgsql',
'host' => 'localhost',
'port' => 5432,
'dbname' => getenv('DB_NAME') ?: 'inventory',
'user' => 'ferme_user',
'password' => 'fermerecette',
],
default => [
'driver' => 'pdo_pgsql',
'host' => 'db',
'port' => 5432,
'dbname' => 'inventory',
'user' => 'root',
'password' => 'root',
],
});
echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n";
$migratedCount = 0;
$deletedCfCount = 0;
$skippedCount = 0;
$conflictCount = 0;
// Process each entity type
$entityTypes = [
[
'label' => 'composant',
'entityTable' => 'composants',
'cfvFk' => 'composantid',
'modelTypeFk' => 'typecomposantid',
'cfModelTypeFk' => 'typecomposantid',
],
[
'label' => 'piece',
'entityTable' => 'pieces',
'cfvFk' => 'pieceid',
'modelTypeFk' => 'typepieceid',
'cfModelTypeFk' => 'typepieceid',
],
[
'label' => 'product',
'entityTable' => 'products',
'cfvFk' => 'productid',
'modelTypeFk' => 'typeproductid',
'cfModelTypeFk' => 'typeproductid',
],
];
foreach ($entityTypes as $et) {
echo sprintf("--- Processing %ss ---\n\n", $et['label']);
// Find all CFValues pointing to orphaned CFs for this entity type
$orphanedValues = $conn->fetchAllAssociative("
SELECT cfv.id as cfv_id, cfv.value, cfv.{$et['cfvFk']} as entity_id,
cf_old.id as old_cf_id, cf_old.name as field_name,
e.name as entity_name, e.{$et['modelTypeFk']} as model_type_id
FROM custom_field_values cfv
JOIN custom_fields cf_old ON cf_old.id = cfv.customfieldid
JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']}
WHERE cfv.{$et['cfvFk']} IS NOT NULL
AND cf_old.{$et['cfModelTypeFk']} IS NULL
AND cf_old.typecomposantid IS NULL
AND cf_old.typepieceid IS NULL
AND cf_old.typeproductid IS NULL
ORDER BY e.name, cf_old.name
");
echo sprintf(" Found %d orphaned custom field values.\n", count($orphanedValues));
foreach ($orphanedValues as $ov) {
if (!$ov['model_type_id']) {
echo sprintf(" SKIP: %s '%s' has no ModelType\n", $et['label'], $ov['entity_name']);
++$skippedCount;
continue;
}
// Find the current CF definition on the ModelType with the same name
$currentCf = $conn->fetchAssociative("
SELECT id FROM custom_fields
WHERE {$et['cfModelTypeFk']} = ? AND name = ?
LIMIT 1
", [$ov['model_type_id'], $ov['field_name']]);
if (!$currentCf) {
echo sprintf(
" WARNING: No current CF '%s' on ModelType %s for %s '%s'\n",
$ov['field_name'],
$ov['model_type_id'],
$et['label'],
$ov['entity_name']
);
++$skippedCount;
continue;
}
// Check if entity already has a CFValue for this current CF
$existingValue = $conn->fetchAssociative("
SELECT id, value FROM custom_field_values
WHERE {$et['cfvFk']} = ? AND customfieldid = ?
", [$ov['entity_id'], $currentCf['id']]);
if ($existingValue) {
// Current CF already has a value for this entity
if ('' !== $existingValue['value'] && null !== $existingValue['value']) {
// Both have values — conflict, skip
if ('' !== $ov['value'] && null !== $ov['value'] && $ov['value'] !== $existingValue['value']) {
echo sprintf(
" CONFLICT: %s '%s' field '%s': old='%s' vs current='%s' — keeping current\n",
$et['label'],
$ov['entity_name'],
$ov['field_name'],
$ov['value'],
$existingValue['value']
);
++$conflictCount;
}
// Delete the orphaned value (current one is kept)
if (!$dryRun) {
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]);
}
echo sprintf(
" DELETE orphaned: %s '%s' field '%s' (current has value '%s')\n",
$et['label'],
$ov['entity_name'],
$ov['field_name'],
$existingValue['value']
);
} else {
// Current value is empty, orphaned has data — update the current one and delete orphaned
if ('' !== $ov['value'] && null !== $ov['value']) {
echo sprintf(
" MIGRATE: %s '%s' field '%s' = '%s'\n",
$et['label'],
$ov['entity_name'],
$ov['field_name'],
$ov['value']
);
if (!$dryRun) {
$conn->executeStatement(
'UPDATE custom_field_values SET value = ? WHERE id = ?',
[$ov['value'], $existingValue['id']]
);
}
++$migratedCount;
}
// Delete the orphaned CFV
if (!$dryRun) {
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]);
}
}
} else {
// No current CFV exists — reassign the orphaned one to the current CF
echo sprintf(
" REASSIGN: %s '%s' field '%s' = '%s'\n",
$et['label'],
$ov['entity_name'],
$ov['field_name'],
$ov['value']
);
if (!$dryRun) {
$conn->executeStatement(
'UPDATE custom_field_values SET customfieldid = ? WHERE id = ?',
[$currentCf['id'], $ov['cfv_id']]
);
}
++$migratedCount;
}
}
echo "\n";
}
// Clean up orphaned CF definitions with no remaining values
echo "--- Cleaning up orphaned CustomField definitions ---\n\n";
$orphanedCfs = $conn->fetchAllAssociative('
SELECT cf.id, cf.name
FROM custom_fields cf
WHERE cf.typecomposantid IS NULL
AND cf.typepieceid IS NULL
AND cf.typeproductid IS NULL
AND NOT EXISTS (SELECT 1 FROM custom_field_values cfv WHERE cfv.customfieldid = cf.id)
ORDER BY cf.name
');
echo sprintf("Found %d orphaned CustomField definitions with no values.\n", count($orphanedCfs));
foreach ($orphanedCfs as $cf) {
if (!$dryRun) {
$conn->executeStatement('DELETE FROM custom_fields WHERE id = ?', [$cf['id']]);
}
++$deletedCfCount;
}
echo sprintf("\n=== SUMMARY ===\n");
echo sprintf("Values migrated/reassigned: %d\n", $migratedCount);
echo sprintf("Orphaned CF definitions deleted: %d\n", $deletedCfCount);
echo sprintf("Skipped: %d\n", $skippedCount);
echo sprintf("Conflicts (kept current): %d\n", $conflictCount);
echo "=== DONE ===\n";

View File

@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
/**
* Script to restore custom field values lost during ModelType sync.
*
* Problem: SkeletonStructureService::updateCustomFields() deletes and recreates
* CustomField definitions when frontend doesn't send IDs. This cascades to
* deleting all CustomFieldValues.
*
* This script:
* 1. Pieces: Restores values from audit_logs (the "from" values in deletion diffs)
* 2. Composants: Removes duplicate empty CustomFieldValues created by sync
*
* Usage: php scripts/restore-custom-field-values.php [--dry-run]
*/
require_once __DIR__.'/../vendor/autoload.php';
use Doctrine\DBAL\DriverManager;
$dryRun = in_array('--dry-run', $argv, true);
$env = getenv('APP_ENV') ?: 'local';
$conn = DriverManager::getConnection(match ($env) {
'prod' => [
'driver' => 'pdo_pgsql',
'host' => 'localhost',
'port' => 5432,
'dbname' => getenv('DB_NAME') ?: 'inventory',
'user' => 'ferme_user',
'password' => 'fermerecette',
],
default => [
'driver' => 'pdo_pgsql',
'host' => 'db',
'port' => 5432,
'dbname' => 'inventory',
'user' => 'root',
'password' => 'root',
],
});
echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n";
// ============================================================
// PART 1: Restore piece custom field values from audit logs
// ============================================================
echo "--- PART 1: Restoring piece custom field values ---\n\n";
// Find all deletion audit entries (where values went from X to null on 2026-03-13)
$deletionLogs = $conn->fetchAllAssociative("
SELECT al.entityid, al.diff::text as diff, p.name as piece_name
FROM audit_logs al
JOIN pieces p ON p.id = al.entityid
WHERE al.entitytype = 'piece'
AND al.action = 'update'
AND al.diff::text LIKE '%\"to\":null%'
AND al.diff::text LIKE '%customField%'
AND al.createdat >= '2026-03-13'
ORDER BY p.name
");
echo sprintf("Found %d pieces with deleted custom field values.\n\n", count($deletionLogs));
$restoredCount = 0;
$errorCount = 0;
foreach ($deletionLogs as $log) {
$pieceId = $log['entityid'];
$pieceName = $log['piece_name'];
$diff = json_decode($log['diff'], true);
foreach ($diff as $key => $change) {
if (!str_starts_with($key, 'customField:')) {
continue;
}
if (null !== $change['to']) {
continue; // Not a deletion
}
$oldValue = $change['from'];
if (null === $oldValue || '' === $oldValue) {
continue; // Nothing to restore
}
$fieldName = substr($key, strlen('customField:'));
// Find the current CustomFieldValue for this piece + field name
$cfv = $conn->fetchAssociative('
SELECT cfv.id, cfv.value, cf.name as field_name
FROM custom_field_values cfv
JOIN custom_fields cf ON cf.id = cfv.customfieldid
WHERE cfv.pieceid = ?
AND cf.name = ?
', [$pieceId, $fieldName]);
if (!$cfv) {
echo sprintf(" WARNING: No CustomFieldValue found for piece '%s' field '%s' — skipping\n", $pieceName, $fieldName);
++$errorCount;
continue;
}
if ('' !== $cfv['value'] && null !== $cfv['value']) {
echo sprintf(" SKIP: Piece '%s' field '%s' already has value '%s' (would restore '%s')\n", $pieceName, $fieldName, $cfv['value'], $oldValue);
continue;
}
echo sprintf(" RESTORE: Piece '%s' field '%s' = '%s'\n", $pieceName, $fieldName, $oldValue);
if (!$dryRun) {
$conn->executeStatement(
'UPDATE custom_field_values SET value = ? WHERE id = ?',
[$oldValue, $cfv['id']]
);
}
++$restoredCount;
}
}
echo sprintf("\nPieces: %d values restored, %d errors.\n\n", $restoredCount, $errorCount);
// ============================================================
// PART 2: Remove duplicate empty composant CustomFieldValues
// ============================================================
echo "--- PART 2: Cleaning duplicate composant custom field values ---\n\n";
// Find composants that have duplicate CFVs (same composantid + same field name, one with value and one empty)
$duplicates = $conn->fetchAllAssociative("
SELECT cfv_empty.id as empty_cfv_id, c.name as composant_name, cf_empty.name as field_name,
cfv_filled.value as existing_value
FROM custom_field_values cfv_empty
JOIN custom_fields cf_empty ON cf_empty.id = cfv_empty.customfieldid
JOIN composants c ON c.id = cfv_empty.composantid
JOIN custom_field_values cfv_filled ON cfv_filled.composantid = cfv_empty.composantid
JOIN custom_fields cf_filled ON cf_filled.id = cfv_filled.customfieldid
WHERE cfv_empty.composantid IS NOT NULL
AND cfv_empty.value = ''
AND cf_empty.name = cf_filled.name
AND cfv_filled.value != ''
AND cfv_filled.id != cfv_empty.id
ORDER BY c.name, cf_empty.name
");
echo sprintf("Found %d duplicate empty custom field values on composants.\n\n", count($duplicates));
$deletedDuplicates = 0;
foreach ($duplicates as $dup) {
echo sprintf(
" DELETE empty duplicate: Composant '%s' field '%s' (has value '%s' in other record)\n",
$dup['composant_name'],
$dup['field_name'],
$dup['existing_value']
);
if (!$dryRun) {
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$dup['empty_cfv_id']]);
}
++$deletedDuplicates;
}
// Also find composants with duplicate empty CFVs (both empty, same field name - keep one, delete the other)
$emptyDuplicates = $conn->fetchAllAssociative("
SELECT cfv2.id as duplicate_id, c.name as composant_name, cf2.name as field_name
FROM custom_field_values cfv1
JOIN custom_fields cf1 ON cf1.id = cfv1.customfieldid
JOIN custom_field_values cfv2 ON cfv2.composantid = cfv1.composantid AND cfv2.id > cfv1.id
JOIN custom_fields cf2 ON cf2.id = cfv2.customfieldid
JOIN composants c ON c.id = cfv1.composantid
WHERE cfv1.composantid IS NOT NULL
AND cfv1.value = ''
AND cfv2.value = ''
AND cf1.name = cf2.name
ORDER BY c.name, cf2.name
");
echo sprintf("\nFound %d duplicate empty-empty custom field values on composants.\n\n", count($emptyDuplicates));
foreach ($emptyDuplicates as $dup) {
echo sprintf(
" DELETE empty-empty duplicate: Composant '%s' field '%s'\n",
$dup['composant_name'],
$dup['field_name']
);
if (!$dryRun) {
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$dup['duplicate_id']]);
}
++$deletedDuplicates;
}
echo sprintf("\nComposants: %d duplicate values removed.\n", $deletedDuplicates);
echo "\n=== DONE ===\n";

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
# ==============================================================================
# Setup remote machines: git aliases + zsh + Oh My Zsh
# Usage: ./scripts/setup-remote-machines.sh user@host1 user@host2 ...
# ==============================================================================
if [ $# -eq 0 ]; then
echo "Usage: $0 user@host1 [user@host2 ...]"
echo "Example: $0 matthieu@192.168.1.10 matthieu@192.168.1.20"
exit 1
fi
REMOTE_SCRIPT='
set -e
echo "=== [1/3] Configuration git aliases ==="
git config --global alias.st "status"
git config --global alias.co "checkout"
git config --global alias.br "branch"
git config --global alias.ci "commit"
git config --global alias.cm "commit -m"
git config --global alias.s "status"
git config --global alias.last "log -1 HEAD"
git config --global alias.unstage "reset HEAD --"
git config --global alias.hist "log --oneline --graph --decorate --all"
git config --global alias.df "diff"
git config --global alias.dc "diff --cached"
git config --global alias.lg "log --oneline --graph --decorate"
git config --global alias.type "cat-file -t"
git config --global alias.dump "cat-file -p"
echo " -> Git aliases OK"
echo "=== [2/3] Installation de zsh ==="
if command -v zsh &>/dev/null; then
echo " -> zsh deja installe ($(zsh --version))"
else
if command -v apt-get &>/dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq zsh
elif command -v dnf &>/dev/null; then
sudo dnf install -y zsh
elif command -v yum &>/dev/null; then
sudo yum install -y zsh
elif command -v pacman &>/dev/null; then
sudo pacman -S --noconfirm zsh
else
echo " !! Gestionnaire de paquets non reconnu, installe zsh manuellement"
exit 1
fi
echo " -> zsh installe"
fi
echo "=== [3/3] Installation de Oh My Zsh ==="
if [ -d "$HOME/.oh-my-zsh" ]; then
echo " -> Oh My Zsh deja installe"
else
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended
echo " -> Oh My Zsh installe"
fi
echo "=== Terminé ! ==="
'
for HOST in "$@"; do
echo ""
echo "========================================"
echo " Configuration de: $HOST"
echo "========================================"
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "$HOST" "$REMOTE_SCRIPT"
# Extraire le user du format user@host
REMOTE_USER="${HOST%%@*}"
echo " -> Changement du shell par defaut en zsh (necessite mot de passe sudo)..."
ssh -t -o ConnectTimeout=10 "$HOST" "sudo chsh -s \$(which zsh) $REMOTE_USER"
echo " -> $HOST OK"
done
echo ""
echo "Toutes les machines ont ete configurees."

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
require_once __DIR__.'/../vendor/autoload.php';
use Doctrine\DBAL\DriverManager;
$conn = DriverManager::getConnection([
'driver' => 'pdo_pgsql',
'host' => 'localhost',
'port' => 5432,
'dbname' => 'inventory',
'user' => 'ferme_user',
'password' => 'fermerecette',
]);
echo "=== CUSTOM FIELDS HEALTH CHECK ===\n\n";
// 1. Orphaned CFs (should be 0)
$orphanedCfs = $conn->fetchOne('SELECT COUNT(*) FROM custom_fields WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL');
echo sprintf("1. Orphaned CF definitions: %d %s\n", $orphanedCfs, 0 == $orphanedCfs ? '[OK]' : '[PROBLEM]');
// 2. Orphaned CFValues (pointing to orphaned CFs)
$orphanedCfvs = $conn->fetchOne('
SELECT COUNT(*) FROM custom_field_values cfv
JOIN custom_fields cf ON cf.id = cfv.customfieldid
WHERE cf.typecomposantid IS NULL AND cf.typepieceid IS NULL AND cf.typeproductid IS NULL
');
echo sprintf("2. Orphaned CF values: %d %s\n", $orphanedCfvs, 0 == $orphanedCfvs ? '[OK]' : '[PROBLEM]');
// 3. Duplicate CFValues (same entity + same field name)
$duplicatePieces = $conn->fetchOne("
SELECT COUNT(*) FROM (
SELECT cfv.pieceid, cf.name, COUNT(*) as cnt
FROM custom_field_values cfv
JOIN custom_fields cf ON cf.id = cfv.customfieldid
WHERE cfv.pieceid IS NOT NULL
GROUP BY cfv.pieceid, cf.name
HAVING COUNT(*) > 1
) t
");
$duplicateComposants = $conn->fetchOne("
SELECT COUNT(*) FROM (
SELECT cfv.composantid, cf.name, COUNT(*) as cnt
FROM custom_field_values cfv
JOIN custom_fields cf ON cf.id = cfv.customfieldid
WHERE cfv.composantid IS NOT NULL
GROUP BY cfv.composantid, cf.name
HAVING COUNT(*) > 1
) t
");
$totalDuplicates = $duplicatePieces + $duplicateComposants;
echo sprintf("3. Duplicate CF values: %d %s\n", $totalDuplicates, 0 == $totalDuplicates ? '[OK]' : '[PROBLEM]');
// 4. Spot check known pieces
echo "\n--- Spot checks ---\n";
$checks = [
['Arbre du palier pied E1', 'cl3d978dd4b071daff8fb185f7', 'pieceid', 'diamètre', '50'],
['Arbre du palier tête E1', 'cmkr0qjw5004s1eq6pen63x7j', 'pieceid', 'diamètre', '70'],
['Cage écureuil pied E1', 'clbe710810fd7ccd09811957b3', 'composantid', 'Diamètre', ''],
];
foreach ($checks as [$name, $id, $fk, $fieldName, $expectedValue]) {
$row = $conn->fetchAssociative(
"SELECT cfv.value FROM custom_field_values cfv JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE cfv.{$fk} = ? AND cf.name = ?",
[$id, $fieldName]
);
$value = $row ? $row['value'] : '(NOT FOUND)';
$ok = '' === $expectedValue ? ('' !== $value && null !== $value) : ($value === $expectedValue);
echo sprintf(" %s → %s = '%s' %s\n", $name, $fieldName, $value, $ok ? '[OK]' : '[CHECK]');
}
// 5. Summary of empty vs filled values
echo "\n--- Value fill rates ---\n";
$stats = $conn->fetchAllAssociative("
SELECT
CASE WHEN cfv.pieceid IS NOT NULL THEN 'piece'
WHEN cfv.composantid IS NOT NULL THEN 'composant'
WHEN cfv.productid IS NOT NULL THEN 'product'
ELSE 'unknown' END as entity_type,
COUNT(*) as total,
COUNT(*) FILTER (WHERE cfv.value != '' AND cfv.value IS NOT NULL) as filled,
COUNT(*) FILTER (WHERE cfv.value = '' OR cfv.value IS NULL) as empty
FROM custom_field_values cfv
GROUP BY entity_type
ORDER BY entity_type
");
foreach ($stats as $s) {
$pct = $s['total'] > 0 ? round(100 * $s['filled'] / $s['total']) : 0;
echo sprintf(" %s: %d/%d filled (%d%%)\n", $s['entity_type'], $s['filled'], $s['total'], $pct);
}
echo "\n=== DONE ===\n";

View File

@@ -0,0 +1,327 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use App\Repository\AuditLogRepository;
use App\Repository\ComposantRepository;
use App\Repository\PieceRepository;
use Doctrine\Common\Collections\Collection;
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 array_key_exists;
use function array_slice;
use function count;
use function iconv;
use function in_array;
use function is_array;
use function is_string;
use function preg_replace;
use function sprintf;
use function str_starts_with;
use function strtolower;
use function trim;
#[AsCommand(
name: 'app:check-missing-custom-field-values',
description: 'List missing or empty custom field values for pieces and composants',
)]
final class CheckMissingCustomFieldValuesCommand extends Command
{
public function __construct(
private readonly PieceRepository $pieces,
private readonly ComposantRepository $composants,
private readonly AuditLogRepository $auditLogs,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('entity', null, InputOption::VALUE_REQUIRED, 'piece, composant or all', 'all')
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Audit entries inspected per entity', '200')
->addOption('max-rows', null, InputOption::VALUE_REQUIRED, 'Maximum rows displayed in the final table', '300')
->addOption('recoverable-only', null, InputOption::VALUE_NONE, 'Show only rows recoverable from audit')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$entityScope = (string) $input->getOption('entity');
$limit = max(1, (int) $input->getOption('limit'));
$maxRows = max(1, (int) $input->getOption('max-rows'));
$recoverableOnly = (bool) $input->getOption('recoverable-only');
if (!in_array($entityScope, ['all', 'piece', 'composant'], true)) {
$io->error('Invalid --entity value. Use: all, piece, composant');
return Command::FAILURE;
}
$rows = [];
$counts = [
'piece' => 0,
'composant' => 0,
];
if ('all' === $entityScope || 'piece' === $entityScope) {
foreach ($this->pieces->findAll() as $piece) {
if (!$piece instanceof Piece) {
continue;
}
$pieceRows = $this->inspectPiece($piece, $limit, $recoverableOnly);
$counts['piece'] += count($pieceRows);
$rows = [...$rows, ...$pieceRows];
}
}
if ('all' === $entityScope || 'composant' === $entityScope) {
foreach ($this->composants->findAll() as $composant) {
if (!$composant instanceof Composant) {
continue;
}
$composantRows = $this->inspectComposant($composant, $limit, $recoverableOnly);
$counts['composant'] += count($composantRows);
$rows = [...$rows, ...$composantRows];
}
}
if ([] === $rows) {
$io->success('No missing or empty custom field values found.');
return Command::SUCCESS;
}
$displayRows = array_slice($rows, 0, $maxRows);
$io->table(
['Entity', 'ID', 'Name', 'Reference', 'Category', 'Field', 'Issue', 'Recoverable', 'Audit value'],
$displayRows,
);
if (count($rows) > $maxRows) {
$io->warning(sprintf('Output truncated: showing %d of %d row(s).', $maxRows, count($rows)));
}
$io->note(sprintf(
'Missing/empty values found: pieces=%d, composants=%d, total=%d.',
$counts['piece'],
$counts['composant'],
count($rows),
));
return Command::SUCCESS;
}
/**
* @return list<array<int, string>>
*/
private function inspectPiece(Piece $piece, int $limit, bool $recoverableOnly): array
{
$type = $piece->getTypePiece();
if (null === $type) {
return [];
}
return $this->inspectEntity(
entityType: 'piece',
entityId: (string) $piece->getId(),
entityName: $piece->getName(),
entityReference: $piece->getReference() ?? '',
typeName: $type->getName(),
definitions: $type->getPieceCustomFields(),
currentValues: $piece->getCustomFieldValues(),
limit: $limit,
recoverableOnly: $recoverableOnly,
);
}
/**
* @return list<array<int, string>>
*/
private function inspectComposant(Composant $composant, int $limit, bool $recoverableOnly): array
{
$type = $composant->getTypeComposant();
if (null === $type) {
return [];
}
return $this->inspectEntity(
entityType: 'composant',
entityId: (string) $composant->getId(),
entityName: $composant->getName(),
entityReference: $composant->getReference() ?? '',
typeName: $type->getName(),
definitions: $type->getComponentCustomFields(),
currentValues: $composant->getCustomFieldValues(),
limit: $limit,
recoverableOnly: $recoverableOnly,
);
}
/**
* @return list<array<int, string>>
*/
private function inspectEntity(
string $entityType,
string $entityId,
string $entityName,
string $entityReference,
string $typeName,
Collection $definitions,
Collection $currentValues,
int $limit,
bool $recoverableOnly,
): array {
if (0 === $definitions->count()) {
return [];
}
$currentValuesByFieldId = $this->indexCurrentValues($currentValues);
$history = $this->auditLogs->findEntityHistory($entityType, $entityId, $limit);
$historicalValues = $this->extractHistoricalValues($history);
$rows = [];
foreach ($definitions as $definition) {
if (!$definition instanceof CustomField) {
continue;
}
$currentValue = $currentValuesByFieldId[$definition->getId()] ?? null;
$issue = null;
if (!$currentValue instanceof CustomFieldValue) {
$issue = 'missing';
} elseif ('' === trim($currentValue->getValue())) {
$issue = 'empty';
}
if (null === $issue) {
continue;
}
$auditCandidate = $historicalValues[$this->normalizeFieldName($definition->getName())] ?? null;
if ($recoverableOnly && null === $auditCandidate) {
continue;
}
$rows[] = [
$entityType,
$entityId,
$entityName,
$entityReference,
$typeName,
$definition->getName(),
$issue,
$auditCandidate ? 'yes' : 'no',
$auditCandidate['value'] ?? '',
];
}
return $rows;
}
/**
* @param list<AuditLog> $history
*
* @return array<string, array{value: string}>
*/
private function extractHistoricalValues(array $history): array
{
$values = [];
foreach ($history as $log) {
$diff = $log->getDiff();
if (!is_array($diff)) {
continue;
}
foreach ($diff as $field => $change) {
if (!is_string($field) || !str_starts_with($field, 'customField:') || !is_array($change)) {
continue;
}
$normalizedName = $this->normalizeFieldName(trim(substr($field, 12)));
if ('' === $normalizedName || array_key_exists($normalizedName, $values)) {
continue;
}
$candidate = $this->extractCandidateValue($change);
if (null === $candidate) {
continue;
}
$values[$normalizedName] = ['value' => $candidate];
}
}
return $values;
}
/**
* @param array{from?: mixed, to?: mixed} $change
*/
private function extractCandidateValue(array $change): ?string
{
$to = $change['to'] ?? null;
if (is_string($to) && '' !== trim($to)) {
return $to;
}
$from = $change['from'] ?? null;
if (is_string($from) && '' !== trim($from)) {
return $from;
}
return null;
}
/**
* @return array<string, CustomFieldValue>
*/
private function indexCurrentValues(Collection $customFieldValues): array
{
$indexed = [];
foreach ($customFieldValues as $customFieldValue) {
if (!$customFieldValue instanceof CustomFieldValue) {
continue;
}
$indexed[$customFieldValue->getCustomField()->getId()] = $customFieldValue;
}
return $indexed;
}
private function normalizeFieldName(string $name): string
{
$normalized = trim($name);
if ('' === $normalized) {
return '';
}
$transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
if (false !== $transliterated) {
$normalized = $transliterated;
}
$normalized = strtolower($normalized);
$normalized = (string) preg_replace('/[^a-z0-9]+/', ' ', $normalized);
return trim((string) preg_replace('/\s+/', ' ', $normalized));
}
}

View File

@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\AuditLog;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use App\Repository\AuditLogRepository;
use App\Repository\PieceRepository;
use Doctrine\Common\Collections\Collection;
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 function array_key_exists;
use function is_array;
use function is_string;
use function ksort;
use function preg_replace;
use function sprintf;
use function str_starts_with;
use function strlen;
use function strtolower;
use function trim;
#[AsCommand(
name: 'app:restore-piece-custom-field-values',
description: 'Restore missing or empty piece custom field values from audit history',
)]
final class RestorePieceCustomFieldValuesCommand extends Command
{
public function __construct(
private readonly PieceRepository $pieces,
private readonly AuditLogRepository $auditLogs,
private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('pieceId', InputArgument::REQUIRED, 'Piece ID to restore')
->addOption('apply', null, InputOption::VALUE_NONE, 'Persist restored values instead of dry-run mode')
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of audit entries to inspect', '500')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$pieceId = (string) $input->getArgument('pieceId');
$apply = (bool) $input->getOption('apply');
$limit = max(1, (int) $input->getOption('limit'));
$piece = $this->pieces->find($pieceId);
if (!$piece instanceof Piece) {
$io->error(sprintf('Piece not found: %s', $pieceId));
return Command::FAILURE;
}
$type = $piece->getTypePiece();
if (null === $type) {
$io->error('This piece has no category (typePiece).');
return Command::FAILURE;
}
$definitions = $type->getPieceCustomFields();
if (0 === $definitions->count()) {
$io->warning('This piece category has no current custom field definitions.');
return Command::SUCCESS;
}
$history = $this->auditLogs->findEntityHistory('piece', $pieceId, $limit);
if ([] === $history) {
$io->warning('No audit history found for this piece.');
return Command::SUCCESS;
}
$historicalValues = $this->extractHistoricalValues($history);
if ([] === $historicalValues) {
$io->warning('No historical custom field values were found in audit logs.');
return Command::SUCCESS;
}
$currentValuesByFieldId = $this->indexCurrentValues($piece->getCustomFieldValues());
$plannedRows = [];
$changesCount = 0;
foreach ($definitions as $definition) {
if (!$definition instanceof CustomField) {
continue;
}
$normalizedName = $this->normalizeFieldName($definition->getName());
if ('' === $normalizedName || !isset($historicalValues[$normalizedName])) {
continue;
}
$currentValue = $currentValuesByFieldId[$definition->getId()] ?? null;
$shouldRestore = null === $currentValue || '' === trim($currentValue->getValue());
if (!$shouldRestore) {
continue;
}
$candidate = $historicalValues[$normalizedName];
$plannedRows[] = [
$definition->getName(),
$candidate['value'],
$candidate['sourceDate'],
$currentValue ? 'update-empty' : 'create-missing',
];
++$changesCount;
if (!$apply) {
continue;
}
if (!$currentValue instanceof CustomFieldValue) {
$currentValue = new CustomFieldValue();
$currentValue->setPiece($piece);
$currentValue->setCustomField($definition);
$this->em->persist($currentValue);
}
$currentValue->setValue($candidate['value']);
}
if (0 === $changesCount) {
$io->success('No missing or empty custom field values needed restoration.');
return Command::SUCCESS;
}
if ($apply) {
$this->em->flush();
}
$io->table(
['Field', 'Restored value', 'Audit date', 'Action'],
$plannedRows,
);
if ($apply) {
$io->success(sprintf('%d custom field value(s) restored.', $changesCount));
} else {
$io->note(sprintf(
'Dry-run only. Re-run with --apply to persist %d restoration(s).',
$changesCount,
));
}
return Command::SUCCESS;
}
/**
* @param list<AuditLog> $history
*
* @return array<string, array{value: string, sourceDate: string, sourceField: string}>
*/
private function extractHistoricalValues(array $history): array
{
$values = [];
foreach ($history as $log) {
$diff = $log->getDiff();
if (!is_array($diff)) {
continue;
}
foreach ($diff as $field => $change) {
if (!is_string($field) || !str_starts_with($field, 'customField:') || !is_array($change)) {
continue;
}
$rawName = trim(substr($field, strlen('customField:')));
$normalizedName = $this->normalizeFieldName($rawName);
if ('' === $normalizedName || array_key_exists($normalizedName, $values)) {
continue;
}
$candidate = $this->extractCandidateValue($change);
if (null === $candidate) {
continue;
}
$values[$normalizedName] = [
'value' => $candidate,
'sourceDate' => $log->getCreatedAt()->format('Y-m-d H:i:s'),
'sourceField' => $rawName,
];
}
}
ksort($values);
return $values;
}
/**
* @param array{from?: mixed, to?: mixed} $change
*/
private function extractCandidateValue(array $change): ?string
{
$to = $change['to'] ?? null;
if (is_string($to) && '' !== trim($to)) {
return $to;
}
$from = $change['from'] ?? null;
if (is_string($from) && '' !== trim($from)) {
return $from;
}
return null;
}
/**
* @return array<string, CustomFieldValue>
*/
private function indexCurrentValues(Collection $customFieldValues): array
{
$indexed = [];
foreach ($customFieldValues as $customFieldValue) {
if (!$customFieldValue instanceof CustomFieldValue) {
continue;
}
$indexed[$customFieldValue->getCustomField()->getId()] = $customFieldValue;
}
return $indexed;
}
private function normalizeFieldName(string $name): string
{
$normalized = trim($name);
if ('' === $normalized) {
return '';
}
$transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
if (false !== $transliterated) {
$normalized = $transliterated;
}
$normalized = strtolower($normalized);
$normalized = (string) preg_replace('/[^a-z0-9]+/', ' ', $normalized);
return trim((string) preg_replace('/\s+/', ' ', $normalized));
}
}

View File

@@ -0,0 +1,311 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\AuditLog;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Repository\AuditLogRepository;
use App\Repository\PieceRepository;
use Doctrine\Common\Collections\Collection;
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 array_key_exists;
use function array_slice;
use function count;
use function iconv;
use function in_array;
use function is_array;
use function is_string;
use function preg_replace;
use function sprintf;
use function str_starts_with;
use function strtolower;
use function trim;
#[AsCommand(
name: 'app:restore-recoverable-piece-custom-field-values',
description: 'Restore all recoverable missing or empty custom field values for pieces',
)]
final class RestoreRecoverablePieceCustomFieldValuesCommand extends Command
{
public function __construct(
private readonly PieceRepository $pieces,
private readonly AuditLogRepository $auditLogs,
private readonly EntityManagerInterface $em,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption('apply', null, InputOption::VALUE_NONE, 'Persist restored values instead of dry-run mode')
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of audit entries to inspect per piece', '500')
->addOption('category', null, InputOption::VALUE_REQUIRED, 'Only process pieces whose ModelType name matches this category')
->addOption('piece-id', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Restrict to one or more piece IDs')
->addOption('max-rows', null, InputOption::VALUE_REQUIRED, 'Maximum rows displayed in the preview table', '300')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$apply = (bool) $input->getOption('apply');
$limit = max(1, (int) $input->getOption('limit'));
$maxRows = max(1, (int) $input->getOption('max-rows'));
$category = $this->normalizeOptionalString($input->getOption('category'));
$pieceIdsRaw = $input->getOption('piece-id');
$pieceIds = is_array($pieceIdsRaw) ? array_values(array_filter(array_map('strval', $pieceIdsRaw))) : [];
$rows = [];
$changesCount = 0;
$pieceCount = 0;
foreach ($this->pieces->findAll() as $piece) {
if (!$piece instanceof Piece) {
continue;
}
if ([] !== $pieceIds && !in_array((string) $piece->getId(), $pieceIds, true)) {
continue;
}
$type = $piece->getTypePiece();
if (!$type instanceof ModelType) {
continue;
}
if (null !== $category && $this->normalizeFieldName($type->getName()) !== $this->normalizeFieldName($category)) {
continue;
}
$pieceRows = $this->restorePiece($piece, $limit, $apply);
if ([] === $pieceRows) {
continue;
}
++$pieceCount;
$changesCount += count($pieceRows);
$rows = [...$rows, ...$pieceRows];
}
if ([] === $rows) {
$io->success('No recoverable piece custom field values found.');
return Command::SUCCESS;
}
$displayRows = array_slice($rows, 0, $maxRows);
$io->table(
['Piece ID', 'Name', 'Reference', 'Category', 'Field', 'Restored value', 'Audit date', 'Action'],
$displayRows,
);
if (count($rows) > $maxRows) {
$io->warning(sprintf('Output truncated: showing %d of %d row(s).', $maxRows, count($rows)));
}
if ($apply) {
$this->em->flush();
$io->success(sprintf('%d value(s) restored across %d piece(s).', $changesCount, $pieceCount));
} else {
$io->note(sprintf(
'Dry-run only. %d value(s) recoverable across %d piece(s). Re-run with --apply to persist.',
$changesCount,
$pieceCount,
));
}
return Command::SUCCESS;
}
/**
* @return list<array<int, string>>
*/
private function restorePiece(Piece $piece, int $limit, bool $apply): array
{
$type = $piece->getTypePiece();
if (!$type instanceof ModelType) {
return [];
}
$definitions = $type->getPieceCustomFields();
if (0 === $definitions->count()) {
return [];
}
$history = $this->auditLogs->findEntityHistory('piece', (string) $piece->getId(), $limit);
if ([] === $history) {
return [];
}
$historicalValues = $this->extractHistoricalValues($history);
if ([] === $historicalValues) {
return [];
}
$currentValuesByFieldId = $this->indexCurrentValues($piece->getCustomFieldValues());
$rows = [];
foreach ($definitions as $definition) {
if (!$definition instanceof CustomField) {
continue;
}
$normalizedName = $this->normalizeFieldName($definition->getName());
$candidate = $historicalValues[$normalizedName] ?? null;
if (null === $candidate) {
continue;
}
$currentValue = $currentValuesByFieldId[$definition->getId()] ?? null;
$shouldRestore = null === $currentValue || '' === trim($currentValue->getValue());
if (!$shouldRestore) {
continue;
}
$action = $currentValue instanceof CustomFieldValue ? 'update-empty' : 'create-missing';
$rows[] = [
(string) $piece->getId(),
$piece->getName(),
$piece->getReference() ?? '',
$type->getName(),
$definition->getName(),
$candidate['value'],
$candidate['sourceDate'],
$action,
];
if (!$apply) {
continue;
}
if (!$currentValue instanceof CustomFieldValue) {
$currentValue = new CustomFieldValue();
$currentValue->setPiece($piece);
$currentValue->setCustomField($definition);
$this->em->persist($currentValue);
}
$currentValue->setValue($candidate['value']);
}
return $rows;
}
/**
* @param list<AuditLog> $history
*
* @return array<string, array{value: string, sourceDate: string}>
*/
private function extractHistoricalValues(array $history): array
{
$values = [];
foreach ($history as $log) {
$diff = $log->getDiff();
if (!is_array($diff)) {
continue;
}
foreach ($diff as $field => $change) {
if (!is_string($field) || !str_starts_with($field, 'customField:') || !is_array($change)) {
continue;
}
$normalizedName = $this->normalizeFieldName(trim(substr($field, 12)));
if ('' === $normalizedName || array_key_exists($normalizedName, $values)) {
continue;
}
$candidate = $this->extractCandidateValue($change);
if (null === $candidate) {
continue;
}
$values[$normalizedName] = [
'value' => $candidate,
'sourceDate' => $log->getCreatedAt()->format('Y-m-d H:i:s'),
];
}
}
return $values;
}
/**
* @param array{from?: mixed, to?: mixed} $change
*/
private function extractCandidateValue(array $change): ?string
{
$to = $change['to'] ?? null;
if (is_string($to) && '' !== trim($to)) {
return $to;
}
$from = $change['from'] ?? null;
if (is_string($from) && '' !== trim($from)) {
return $from;
}
return null;
}
/**
* @return array<string, CustomFieldValue>
*/
private function indexCurrentValues(Collection $customFieldValues): array
{
$indexed = [];
foreach ($customFieldValues as $customFieldValue) {
if (!$customFieldValue instanceof CustomFieldValue) {
continue;
}
$indexed[$customFieldValue->getCustomField()->getId()] = $customFieldValue;
}
return $indexed;
}
private function normalizeFieldName(string $name): string
{
$normalized = trim($name);
if ('' === $normalized) {
return '';
}
$transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
if (false !== $transliterated) {
$normalized = $transliterated;
}
$normalized = strtolower($normalized);
$normalized = (string) preg_replace('/[^a-z0-9]+/', ' ', $normalized);
return trim((string) preg_replace('/\s+/', ' ', $normalized));
}
private function normalizeOptionalString(mixed $value): ?string
{
if (!is_string($value)) {
return null;
}
$trimmed = trim($value);
return '' === $trimmed ? null : $trimmed;
}
}

View File

@@ -68,6 +68,9 @@ final class AdminProfileController extends AbstractController
} }
if (null !== $password && '' !== $password) { if (null !== $password && '' !== $password) {
if (mb_strlen($password) < 8) {
return new JsonResponse(['message' => 'Le mot de passe doit contenir au moins 8 caractères.'], JsonResponse::HTTP_BAD_REQUEST);
}
$profile->setPassword( $profile->setPassword(
$this->passwordHasher->hashPassword($profile, $password) $this->passwordHasher->hashPassword($profile, $password)
); );
@@ -131,6 +134,10 @@ final class AdminProfileController extends AbstractController
return new JsonResponse(['message' => 'Le mot de passe est requis.'], JsonResponse::HTTP_BAD_REQUEST); return new JsonResponse(['message' => 'Le mot de passe est requis.'], JsonResponse::HTTP_BAD_REQUEST);
} }
if (mb_strlen($password) < 8) {
return new JsonResponse(['message' => 'Le mot de passe doit contenir au moins 8 caractères.'], JsonResponse::HTTP_BAD_REQUEST);
}
$profile->setPassword( $profile->setPassword(
$this->passwordHasher->hashPassword($profile, $password) $this->passwordHasher->hashPassword($profile, $password)
); );

View File

@@ -5,11 +5,15 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Comment; use App\Entity\Comment;
use App\Entity\Document;
use App\Enum\DocumentType;
use App\Repository\ProfileRepository; use App\Repository\ProfileRepository;
use App\Service\DocumentStorageService;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeInterface; use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -20,6 +24,7 @@ final class CommentController extends AbstractController
public function __construct( public function __construct(
private readonly EntityManagerInterface $entityManager, private readonly EntityManagerInterface $entityManager,
private readonly ProfileRepository $profiles, private readonly ProfileRepository $profiles,
private readonly DocumentStorageService $storageService,
) {} ) {}
#[Route('', name: 'api_comments_create', methods: ['POST'])] #[Route('', name: 'api_comments_create', methods: ['POST'])]
@@ -38,15 +43,24 @@ final class CommentController extends AbstractController
return $this->json(['message' => 'Profil introuvable.'], 401); return $this->json(['message' => 'Profil introuvable.'], 401);
} }
// Parse fields from JSON or form-data
$contentType = $request->headers->get('Content-Type', '');
$isFormData = str_contains($contentType, 'multipart/form-data') || $request->files->count() > 0 || $request->request->has('content');
if ($isFormData) {
$content = trim((string) $request->request->get('content', ''));
$entityType = trim((string) $request->request->get('entityType', ''));
$entityId = trim((string) $request->request->get('entityId', ''));
$entityName = $request->request->get('entityName') ? trim((string) $request->request->get('entityName')) : null;
} else {
$payload = json_decode($request->getContent(), true); $payload = json_decode($request->getContent(), true);
if (!is_array($payload)) { if (!is_array($payload)) {
return $this->json(['message' => 'Payload JSON invalide.'], 400); return $this->json(['message' => 'Payload JSON invalide.'], 400);
} }
$content = trim((string) ($payload['content'] ?? '')); $content = trim((string) ($payload['content'] ?? ''));
$entityType = trim((string) ($payload['entityType'] ?? '')); $entityType = trim((string) ($payload['entityType'] ?? ''));
$entityId = trim((string) ($payload['entityId'] ?? '')); $entityId = trim((string) ($payload['entityId'] ?? ''));
$entityName = isset($payload['entityName']) ? trim((string) $payload['entityName']) : null; $entityName = isset($payload['entityName']) ? trim((string) $payload['entityName']) : null;
}
if ('' === $content) { if ('' === $content) {
return $this->json(['message' => 'Le contenu est requis.'], 400); return $this->json(['message' => 'Le contenu est requis.'], 400);
@@ -75,6 +89,53 @@ final class CommentController extends AbstractController
$comment->setAuthorName($authorName); $comment->setAuthorName($authorName);
$this->entityManager->persist($comment); $this->entityManager->persist($comment);
// Handle file uploads
$allowedMimeTypes = [
'application/pdf',
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp',
'text/plain', 'text/csv',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/msword', 'application/vnd.ms-excel',
'application/zip',
];
$files = $request->files->all('files');
foreach ($files as $file) {
if (!$file instanceof UploadedFile || !$file->isValid()) {
continue;
}
$detectedMime = $file->getMimeType() ?: 'application/octet-stream';
if (!in_array($detectedMime, $allowedMimeTypes, true)) {
return $this->json([
'message' => sprintf('Type de fichier non autorisé : %s', $detectedMime),
], 400);
}
$document = new Document();
$documentId = 'cl'.bin2hex(random_bytes(12));
$document->setId($documentId);
$document->setName($file->getClientOriginalName());
$document->setFilename($file->getClientOriginalName());
$document->setMimeType($file->getMimeType() ?: 'application/octet-stream');
$document->setSize((int) $file->getSize());
$document->setType(DocumentType::DOCUMENTATION);
$document->setComment($comment);
$comment->getDocuments()->add($document);
$extension = $this->storageService->extensionFromFilename($file->getClientOriginalName());
$relativePath = $this->storageService->storeFromPath(
$file->getPathname(),
$documentId,
$extension,
);
$document->setPath($relativePath);
$this->entityManager->persist($document);
}
$this->entityManager->flush(); $this->entityManager->flush();
return $this->json($this->normalize($comment), 201); return $this->json($this->normalize($comment), 201);
@@ -112,6 +173,76 @@ final class CommentController extends AbstractController
return $this->json($this->normalize($comment)); return $this->json($this->normalize($comment));
} }
#[Route('/search/list', name: 'api_comments_list', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$qb = $this->entityManager->getRepository(Comment::class)->createQueryBuilder('c');
$status = $request->query->get('status');
if ($status) {
$qb->andWhere('c.status = :status')->setParameter('status', $status);
}
$entityType = $request->query->get('entityType');
if ($entityType) {
$qb->andWhere('c.entityType = :entityType')->setParameter('entityType', $entityType);
}
$entityName = $request->query->get('entityName');
if ($entityName) {
$qb->andWhere('LOWER(c.entityName) LIKE LOWER(:entityName)')->setParameter('entityName', '%'.$entityName.'%');
}
// Count total before pagination
$countQb = clone $qb;
$total = (int) $countQb->select('COUNT(c.id)')->getQuery()->getSingleScalarResult();
// Sorting
$sortField = $request->query->get('sort', 'createdAt');
$sortDir = strtoupper($request->query->get('direction', 'DESC'));
$allowedSortFields = ['createdAt', 'authorName', 'status'];
if (!in_array($sortField, $allowedSortFields, true)) {
$sortField = 'createdAt';
}
if (!in_array($sortDir, ['ASC', 'DESC'], true)) {
$sortDir = 'DESC';
}
$qb->orderBy('c.'.$sortField, $sortDir);
// Pagination
$itemsPerPage = min((int) $request->query->get('itemsPerPage', '30'), 200);
$page = max((int) $request->query->get('page', '1'), 1);
$qb->setMaxResults($itemsPerPage)->setFirstResult(($page - 1) * $itemsPerPage);
$comments = $qb->getQuery()->getResult();
return $this->json([
'items' => array_map(fn (Comment $c) => $this->normalize($c), $comments),
'total' => $total,
]);
}
#[Route('/by-entity/{entityType}/{entityId}', name: 'api_comments_by_entity', methods: ['GET'])]
public function listByEntity(string $entityType, string $entityId, Request $request): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$criteria = ['entityType' => $entityType, 'entityId' => $entityId];
$status = $request->query->get('status');
if ($status) {
$criteria['status'] = $status;
}
$comments = $this->entityManager->getRepository(Comment::class)
->findBy($criteria, ['createdAt' => 'DESC'])
;
return $this->json(array_map(fn (Comment $c) => $this->normalize($c), $comments));
}
#[Route('/stats/unresolved-count', name: 'api_comments_unresolved_count', methods: ['GET'])] #[Route('/stats/unresolved-count', name: 'api_comments_unresolved_count', methods: ['GET'])]
public function unresolvedCount(): JsonResponse public function unresolvedCount(): JsonResponse
{ {
@@ -126,6 +257,21 @@ final class CommentController extends AbstractController
private function normalize(Comment $comment): array private function normalize(Comment $comment): array
{ {
$documents = [];
foreach ($comment->getDocuments() as $document) {
$documents[] = [
'id' => $document->getId(),
'name' => $document->getName(),
'filename' => $document->getFilename(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'type' => $document->getType()->value,
'fileUrl' => '/api/documents/'.$document->getId().'/file',
'downloadUrl' => '/api/documents/'.$document->getId().'/download',
'createdAt' => $document->getCreatedAt()->format(DateTimeInterface::ATOM),
];
}
return [ return [
'id' => $comment->getId(), 'id' => $comment->getId(),
'content' => $comment->getContent(), 'content' => $comment->getContent(),
@@ -140,6 +286,7 @@ final class CommentController extends AbstractController
'resolvedAt' => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM), 'resolvedAt' => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM),
'createdAt' => $comment->getCreatedAt()->format(DateTimeInterface::ATOM), 'createdAt' => $comment->getCreatedAt()->format(DateTimeInterface::ATOM),
'updatedAt' => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM), 'updatedAt' => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM),
'documents' => $documents,
]; ];
} }
} }

View File

@@ -43,6 +43,18 @@ class ComposantPieceSlotController extends AbstractController
$slot->setSelectedPiece(null); $slot->setSelectedPiece(null);
} else { } else {
$piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']); $piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']);
if (!$piece) {
return $this->json(['success' => false, 'error' => 'Pièce introuvable.'], 404);
}
$slotTypePiece = $slot->getTypePiece();
if ($slotTypePiece && $piece->getTypePiece()?->getId() !== $slotTypePiece->getId()) {
return $this->json([
'success' => false,
'error' => sprintf('La pièce doit être de type « %s ».', $slotTypePiece->getName()),
], 422);
}
$slot->setSelectedPiece($piece); $slot->setSelectedPiece($piece);
} }
} }

View File

@@ -196,19 +196,16 @@ class CustomFieldValueController extends AbstractController
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400); return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
} }
$customField = new CustomField(); // Try to find an existing custom field by name instead of creating an orphan
$customField->setName($customFieldName); $existing = $this->customFieldRepository->findOneBy(['name' => $customFieldName]);
$customField->setType((string) ($payload['customFieldType'] ?? 'text')); if ($existing instanceof CustomField) {
$customField->setRequired((bool) ($payload['customFieldRequired'] ?? false)); return $existing;
$options = $payload['customFieldOptions'] ?? null;
if (is_array($options)) {
$customField->setOptions($options);
} }
$this->entityManager->persist($customField); return $this->json([
'success' => false,
return $customField; 'error' => sprintf('Custom field "%s" not found. Create it explicitly first.', $customFieldName),
], 404);
} }
private function resolveTarget(array $payload): array|JsonResponse private function resolveTarget(array $payload): array|JsonResponse

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Comment;
use App\Entity\Document; use App\Entity\Document;
use App\Repository\ComposantRepository; use App\Repository\ComposantRepository;
use App\Repository\DocumentRepository; use App\Repository\DocumentRepository;
@@ -11,6 +12,7 @@ use App\Repository\MachineRepository;
use App\Repository\PieceRepository; use App\Repository\PieceRepository;
use App\Repository\ProductRepository; use App\Repository\ProductRepository;
use App\Repository\SiteRepository; use App\Repository\SiteRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -25,6 +27,7 @@ class DocumentQueryController extends AbstractController
private readonly ComposantRepository $composantRepository, private readonly ComposantRepository $composantRepository,
private readonly PieceRepository $pieceRepository, private readonly PieceRepository $pieceRepository,
private readonly ProductRepository $productRepository, private readonly ProductRepository $productRepository,
private readonly EntityManagerInterface $em,
) {} ) {}
#[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])] #[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])]
@@ -102,6 +105,21 @@ class DocumentQueryController extends AbstractController
return $this->json($this->normalizeDocuments($documents)); return $this->json($this->normalizeDocuments($documents));
} }
#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
public function listByComment(string $id): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$comment = $this->em->find(Comment::class, $id);
if (!$comment) {
return $this->json(['success' => false, 'error' => 'Comment not found.'], 404);
}
$documents = $this->documentRepository->findBy(['comment' => $comment]);
return $this->json($this->normalizeDocuments($documents));
}
/** /**
* @param Document[] $documents * @param Document[] $documents
*/ */
@@ -121,6 +139,8 @@ class DocumentQueryController extends AbstractController
'composantId' => $document->getComposant()?->getId(), 'composantId' => $document->getComposant()?->getId(),
'pieceId' => $document->getPiece()?->getId(), 'pieceId' => $document->getPiece()?->getId(),
'productId' => $document->getProduct()?->getId(), 'productId' => $document->getProduct()?->getId(),
'commentId' => $document->getComment()?->getId(),
'type' => $document->getType()->value,
'createdAt' => $document->getCreatedAt()->format(DATE_ATOM), 'createdAt' => $document->getCreatedAt()->format(DATE_ATOM),
'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM), 'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM),
]; ];

View File

@@ -8,6 +8,7 @@ use App\Repository\DocumentRepository;
use App\Service\DocumentStorageService; use App\Service\DocumentStorageService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -42,11 +43,15 @@ class DocumentServeController extends AbstractController
return $this->json(['error' => 'Invalid document data.'], 500); return $this->json(['error' => 'Invalid document data.'], 500);
} }
$disposition = HeaderUtils::makeDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $document->getFilename());
return new Response($content, 200, [ return new Response($content, 200, [
'Content-Type' => $document->getMimeType(), 'Content-Type' => $document->getMimeType(),
'Content-Disposition' => ResponseHeaderBag::DISPOSITION_INLINE.'; filename="'.$document->getFilename().'"', 'Content-Disposition' => $disposition,
'Content-Length' => (string) strlen($content), 'Content-Length' => (string) strlen($content),
'Cache-Control' => 'private, max-age=3600', 'Cache-Control' => 'private, max-age=3600',
'X-Content-Type-Options' => 'nosniff',
'Content-Security-Policy' => 'sandbox',
]); ]);
} }
@@ -63,6 +68,8 @@ class DocumentServeController extends AbstractController
$document->getFilename() $document->getFilename()
); );
$response->headers->set('Cache-Control', 'private, max-age=3600'); $response->headers->set('Cache-Control', 'private, max-age=3600');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('Content-Security-Policy', 'sandbox');
return $response; return $response;
} }
@@ -86,9 +93,11 @@ class DocumentServeController extends AbstractController
return $this->json(['error' => 'Invalid document data.'], 500); return $this->json(['error' => 'Invalid document data.'], 500);
} }
$disposition = HeaderUtils::makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $document->getFilename());
return new Response($content, 200, [ return new Response($content, 200, [
'Content-Type' => 'application/octet-stream', 'Content-Type' => 'application/octet-stream',
'Content-Disposition' => ResponseHeaderBag::DISPOSITION_ATTACHMENT.'; filename="'.$document->getFilename().'"', 'Content-Disposition' => $disposition,
'Content-Length' => (string) strlen($content), 'Content-Length' => (string) strlen($content),
]); ]);
} }

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\ComposantRepository;
use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use App\Service\EntityVersionService;
use InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class EntityVersionController extends AbstractController
{
/** @var array<string, array{repo: object, label: string}> */
private readonly array $entityConfig;
public function __construct(
MachineRepository $machines,
PieceRepository $pieces,
ComposantRepository $composants,
ProductRepository $products,
private readonly EntityVersionService $versionService,
) {
$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.'],
];
}
// ── Versions list ───────────────────────────────────────────────
#[Route('/api/machines/{id}/versions', name: 'api_machine_versions', methods: ['GET'])]
public function machineVersions(string $id): JsonResponse
{
return $this->listVersions('machine', $id);
}
#[Route('/api/composants/{id}/versions', name: 'api_composant_versions', methods: ['GET'])]
public function composantVersions(string $id): JsonResponse
{
return $this->listVersions('composant', $id);
}
#[Route('/api/pieces/{id}/versions', name: 'api_piece_versions', methods: ['GET'])]
public function pieceVersions(string $id): JsonResponse
{
return $this->listVersions('piece', $id);
}
#[Route('/api/products/{id}/versions', name: 'api_product_versions', methods: ['GET'])]
public function productVersions(string $id): JsonResponse
{
return $this->listVersions('product', $id);
}
// ── Preview ─────────────────────────────────────────────────────
#[Route('/api/machines/{id}/versions/{version}/preview', name: 'api_machine_version_preview', methods: ['GET'])]
public function machineVersionPreview(string $id, int $version): JsonResponse
{
return $this->preview('machine', $id, $version);
}
#[Route('/api/composants/{id}/versions/{version}/preview', name: 'api_composant_version_preview', methods: ['GET'])]
public function composantVersionPreview(string $id, int $version): JsonResponse
{
return $this->preview('composant', $id, $version);
}
#[Route('/api/pieces/{id}/versions/{version}/preview', name: 'api_piece_version_preview', methods: ['GET'])]
public function pieceVersionPreview(string $id, int $version): JsonResponse
{
return $this->preview('piece', $id, $version);
}
#[Route('/api/products/{id}/versions/{version}/preview', name: 'api_product_version_preview', methods: ['GET'])]
public function productVersionPreview(string $id, int $version): JsonResponse
{
return $this->preview('product', $id, $version);
}
// ── Restore ─────────────────────────────────────────────────────
#[Route('/api/machines/{id}/versions/{version}/restore', name: 'api_machine_version_restore', methods: ['POST'])]
public function machineVersionRestore(string $id, int $version): JsonResponse
{
return $this->restoreVersion('machine', $id, $version);
}
#[Route('/api/composants/{id}/versions/{version}/restore', name: 'api_composant_version_restore', methods: ['POST'])]
public function composantVersionRestore(string $id, int $version): JsonResponse
{
return $this->restoreVersion('composant', $id, $version);
}
#[Route('/api/pieces/{id}/versions/{version}/restore', name: 'api_piece_version_restore', methods: ['POST'])]
public function pieceVersionRestore(string $id, int $version): JsonResponse
{
return $this->restoreVersion('piece', $id, $version);
}
#[Route('/api/products/{id}/versions/{version}/restore', name: 'api_product_version_restore', methods: ['POST'])]
public function productVersionRestore(string $id, int $version): JsonResponse
{
return $this->restoreVersion('product', $id, $version);
}
// ── Private helpers ─────────────────────────────────────────────
private function listVersions(string $entityType, string $entityId): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_VIEWER');
$config = $this->entityConfig[$entityType];
if (!$config['repo']->find($entityId)) {
return new JsonResponse(['message' => $config['label']], Response::HTTP_NOT_FOUND);
}
return new JsonResponse($this->versionService->getVersions($entityType, $entityId));
}
private function preview(string $entityType, string $entityId, int $version): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
try {
$result = $this->versionService->getRestorePreview($entityType, $entityId, $version);
return new JsonResponse($result);
} catch (InvalidArgumentException $e) {
return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND);
}
}
private function restoreVersion(string $entityType, string $entityId, int $version): JsonResponse
{
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
try {
$result = $this->versionService->restore($entityType, $entityId, $version);
return new JsonResponse($result);
} catch (InvalidArgumentException $e) {
return new JsonResponse(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND);
}
}
}

View File

@@ -9,6 +9,7 @@ use App\Entity\CustomField;
use App\Entity\CustomFieldValue; use App\Entity\CustomFieldValue;
use App\Entity\Machine; use App\Entity\Machine;
use App\Entity\MachineComponentLink; use App\Entity\MachineComponentLink;
use App\Entity\MachineConstructeurLink;
use App\Entity\MachinePieceLink; use App\Entity\MachinePieceLink;
use App\Entity\MachineProductLink; use App\Entity\MachineProductLink;
use App\Entity\ModelType; use App\Entity\ModelType;
@@ -53,9 +54,9 @@ class MachineStructureController extends AbstractController
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404); return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
} }
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine]); $componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]); $pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]); $productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
return $this->json($this->normalizeStructureResponse( return $this->json($this->normalizeStructureResponse(
$machine, $machine,
@@ -138,9 +139,13 @@ class MachineStructureController extends AbstractController
} }
$newMachine->setPrix($source->getPrix()); $newMachine->setPrix($source->getPrix());
// Copy constructeurs // Copy constructeur links
foreach ($source->getConstructeurs() as $constructeur) { foreach ($source->getConstructeurLinks() as $link) {
$newMachine->getConstructeurs()->add($constructeur); $newLink = new MachineConstructeurLink();
$newLink->setMachine($newMachine);
$newLink->setConstructeur($link->getConstructeur());
$newLink->setSupplierReference($link->getSupplierReference());
$this->entityManager->persist($newLink);
} }
$this->entityManager->persist($newMachine); $this->entityManager->persist($newMachine);
@@ -159,9 +164,9 @@ class MachineStructureController extends AbstractController
$this->entityManager->flush(); $this->entityManager->flush();
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine]); $componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $newMachine]); $pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $newMachine]); $productLinks = $this->machineProductLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
return $this->json($this->normalizeStructureResponse( return $this->json($this->normalizeStructureResponse(
$newMachine, $newMachine,
@@ -173,6 +178,8 @@ class MachineStructureController extends AbstractController
private function cloneCustomFields(Machine $source, Machine $target): void private function cloneCustomFields(Machine $source, Machine $target): void
{ {
$cfMap = [];
foreach ($source->getCustomFields() as $cf) { foreach ($source->getCustomFields() as $cf) {
$newCf = new CustomField(); $newCf = new CustomField();
$newCf->setName($cf->getName()); $newCf->setName($cf->getName());
@@ -183,12 +190,20 @@ class MachineStructureController extends AbstractController
$newCf->setOrderIndex($cf->getOrderIndex()); $newCf->setOrderIndex($cf->getOrderIndex());
$newCf->setMachine($target); $newCf->setMachine($target);
$this->entityManager->persist($newCf); $this->entityManager->persist($newCf);
$cfMap[$cf->getId()] = $newCf;
} }
foreach ($source->getCustomFieldValues() as $cfv) { foreach ($source->getCustomFieldValues() as $cfv) {
$originalCf = $cfv->getCustomField();
$newCf = $cfMap[$originalCf->getId()] ?? null;
if (!$newCf) {
continue;
}
$newValue = new CustomFieldValue(); $newValue = new CustomFieldValue();
$newValue->setMachine($target); $newValue->setMachine($target);
$newValue->setCustomField($cfv->getCustomField()); $newValue->setCustomField($newCf);
$newValue->setValue($cfv->getValue()); $newValue->setValue($cfv->getValue());
$this->entityManager->persist($newValue); $this->entityManager->persist($newValue);
} }
@@ -199,7 +214,7 @@ class MachineStructureController extends AbstractController
*/ */
private function cloneComponentLinks(Machine $source, Machine $target): array private function cloneComponentLinks(Machine $source, Machine $target): array
{ {
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source]); $sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
$linkMap = []; $linkMap = [];
// First pass: create all links without parent relationships // First pass: create all links without parent relationships
@@ -232,7 +247,7 @@ class MachineStructureController extends AbstractController
*/ */
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array
{ {
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source]); $sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
$linkMap = []; $linkMap = [];
foreach ($sourceLinks as $link) { foreach ($sourceLinks as $link) {
@@ -266,7 +281,7 @@ class MachineStructureController extends AbstractController
array $componentLinkMap, array $componentLinkMap,
array $pieceLinkMap, array $pieceLinkMap,
): void { ): void {
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source]); $sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
$linkMap = []; $linkMap = [];
// First pass: create all links // First pass: create all links
@@ -309,7 +324,7 @@ class MachineStructureController extends AbstractController
private function applyComponentLinks(Machine $machine, array $payload): array|JsonResponse private function applyComponentLinks(Machine $machine, array $payload): array|JsonResponse
{ {
$existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine])); $existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']));
$keepIds = []; $keepIds = [];
$pendingParents = []; $pendingParents = [];
$links = []; $links = [];
@@ -366,7 +381,7 @@ class MachineStructureController extends AbstractController
private function applyPieceLinks(Machine $machine, array $payload, array $componentLinks): array|JsonResponse private function applyPieceLinks(Machine $machine, array $payload, array $componentLinks): array|JsonResponse
{ {
$existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine])); $existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']));
$componentIndex = $this->indexLinksById($componentLinks); $componentIndex = $this->indexLinksById($componentLinks);
$keepIds = []; $keepIds = [];
$pendingParents = []; $pendingParents = [];
@@ -433,7 +448,7 @@ class MachineStructureController extends AbstractController
array $componentLinks, array $componentLinks,
array $pieceLinks, array $pieceLinks,
): array|JsonResponse { ): array|JsonResponse {
$existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine])); $existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']));
$componentIndex = $this->indexLinksById($componentLinks); $componentIndex = $this->indexLinksById($componentLinks);
$pieceIndex = $this->indexLinksById($pieceLinks); $pieceIndex = $this->indexLinksById($pieceLinks);
$keepIds = []; $keepIds = [];
@@ -576,7 +591,7 @@ class MachineStructureController extends AbstractController
'id' => $site->getId(), 'id' => $site->getId(),
'name' => $site->getName(), 'name' => $site->getName(),
], ],
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()), 'constructeurs' => $this->normalizeConstructeurLinks($machine->getConstructeurLinks()),
'customFields' => $this->normalizeCustomFields($machine->getCustomFields()), 'customFields' => $this->normalizeCustomFields($machine->getCustomFields()),
'documents' => null, 'documents' => null,
'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()), 'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()),
@@ -699,7 +714,7 @@ class MachineStructureController extends AbstractController
'productId' => $composant->getProduct()?->getId(), 'productId' => $composant->getProduct()?->getId(),
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null, 'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
'structure' => $this->buildStructureFromSlots($composant), 'structure' => $this->buildStructureFromSlots($composant),
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()), 'constructeurs' => $this->normalizeConstructeurLinks($composant->getConstructeurLinks()),
'documents' => [], 'documents' => [],
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [], 'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [],
'customFieldValues' => $this->normalizeCustomFieldValues($composant->getCustomFieldValues()), 'customFieldValues' => $this->normalizeCustomFieldValues($composant->getCustomFieldValues()),
@@ -761,7 +776,7 @@ class MachineStructureController extends AbstractController
'typePiece' => $this->normalizeModelType($type), 'typePiece' => $this->normalizeModelType($type),
'productId' => $piece->getProduct()?->getId(), 'productId' => $piece->getProduct()?->getId(),
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null, 'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()), 'constructeurs' => $this->normalizeConstructeurLinks($piece->getConstructeurLinks()),
'documents' => [], 'documents' => [],
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getPieceCustomFields()) : [], 'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getPieceCustomFields()) : [],
'customFieldValues' => $this->normalizeCustomFieldValues($piece->getCustomFieldValues()), 'customFieldValues' => $this->normalizeCustomFieldValues($piece->getCustomFieldValues()),
@@ -779,7 +794,7 @@ class MachineStructureController extends AbstractController
'supplierPrice' => $product->getSupplierPrice(), 'supplierPrice' => $product->getSupplierPrice(),
'typeProductId' => $type?->getId(), 'typeProductId' => $type?->getId(),
'typeProduct' => $this->normalizeModelType($type), 'typeProduct' => $this->normalizeModelType($type),
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()), 'constructeurs' => $this->normalizeConstructeurLinks($product->getConstructeurLinks()),
'documents' => [], 'documents' => [],
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getProductCustomFields()) : [], 'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getProductCustomFields()) : [],
'customFieldValues' => $this->normalizeCustomFieldValues($product->getCustomFieldValues()), 'customFieldValues' => $this->normalizeCustomFieldValues($product->getCustomFieldValues()),
@@ -801,15 +816,19 @@ class MachineStructureController extends AbstractController
]; ];
} }
private function normalizeConstructeurs(Collection $constructeurs): array private function normalizeConstructeurLinks(Collection $constructeurLinks): array
{ {
$items = []; $items = [];
foreach ($constructeurs as $constructeur) { foreach ($constructeurLinks as $link) {
$items[] = [ $items[] = [
'id' => $constructeur->getId(), 'id' => $link->getId(),
'name' => $constructeur->getName(), 'constructeur' => [
'email' => $constructeur->getEmail(), 'id' => $link->getConstructeur()->getId(),
'phone' => $constructeur->getPhone(), 'name' => $link->getConstructeur()->getName(),
'email' => $link->getConstructeur()->getEmail(),
'phone' => $link->getConstructeur()->getPhone(),
],
'supplierReference' => $link->getSupplierReference(),
]; ];
} }

View File

@@ -5,10 +5,12 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Repository\ProfileRepository; use App\Repository\ProfileRepository;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
final class SessionProfileController final class SessionProfileController
@@ -16,6 +18,8 @@ final class SessionProfileController
public function __construct( public function __construct(
private readonly ProfileRepository $profiles, private readonly ProfileRepository $profiles,
private readonly UserPasswordHasherInterface $passwordHasher, private readonly UserPasswordHasherInterface $passwordHasher,
#[Autowire(service: 'limiter.login')]
private readonly RateLimiterFactory $loginLimiter,
) {} ) {}
#[Route('/api/session/profile', name: 'api_session_profile_get', methods: ['GET'])] #[Route('/api/session/profile', name: 'api_session_profile_get', methods: ['GET'])]
@@ -56,6 +60,11 @@ final class SessionProfileController
return new JsonResponse(['message' => 'Session indisponible.'], JsonResponse::HTTP_INTERNAL_SERVER_ERROR); return new JsonResponse(['message' => 'Session indisponible.'], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
} }
$limiter = $this->loginLimiter->create($request->getClientIp());
if (!$limiter->consume()->isAccepted()) {
return new JsonResponse(['message' => 'Trop de tentatives. Réessayez dans une minute.'], JsonResponse::HTTP_TOO_MANY_REQUESTS);
}
$payload = $request->toArray(); $payload = $request->toArray();
$profileId = $payload['profileId'] ?? null; $profileId = $payload['profileId'] ?? null;
@@ -64,24 +73,24 @@ final class SessionProfileController
} }
$profile = $this->profiles->find($profileId); $profile = $this->profiles->find($profileId);
if (!$profile || !$profile->isActive()) {
return new JsonResponse(['message' => 'Profil introuvable ou inactif.'], JsonResponse::HTTP_UNAUTHORIZED);
}
$password = $payload['password'] ?? ''; $password = $payload['password'] ?? '';
if ('' === $password) { if ('' === $password) {
return new JsonResponse(['message' => 'Mot de passe requis.'], JsonResponse::HTTP_BAD_REQUEST); return new JsonResponse(['message' => 'Mot de passe requis.'], JsonResponse::HTTP_BAD_REQUEST);
} }
$loginFailed = new JsonResponse(['message' => 'Identifiants invalides.'], JsonResponse::HTTP_UNAUTHORIZED);
if (!$profile || !$profile->isActive()) {
return $loginFailed;
}
if (!$profile->getPassword()) { if (!$profile->getPassword()) {
return new JsonResponse( return $loginFailed;
['message' => 'Ce profil n\'a pas de mot de passe. Contactez un administrateur.'],
JsonResponse::HTTP_FORBIDDEN,
);
} }
if (!$this->passwordHasher->isPasswordValid($profile, $password)) { if (!$this->passwordHasher->isPasswordValid($profile, $password)) {
return new JsonResponse(['message' => 'Mot de passe incorrect.'], JsonResponse::HTTP_UNAUTHORIZED); return $loginFailed;
} }
$session->migrate(true); $session->migrate(true);

View File

@@ -30,7 +30,6 @@ final class SessionProfilesController
'id' => $profile->getId(), 'id' => $profile->getId(),
'firstName' => $profile->getFirstName(), 'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(), 'lastName' => $profile->getLastName(),
'hasPassword' => null !== $profile->getPassword() && '' !== $profile->getPassword(),
]; ];
}, $items)); }, $items));
} }

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Composant;
use App\Entity\Piece;
use App\Entity\Product;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\HttpFoundation\RequestStack;
use function in_array;
use function is_string;
final class SearchByNameOrReferenceExtension implements QueryCollectionExtensionInterface
{
private const SUPPORTED_CLASSES = [
Piece::class,
Composant::class,
Product::class,
];
public function __construct(
private readonly RequestStack $requestStack,
) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
if (!in_array($resourceClass, self::SUPPORTED_CLASSES, true)) {
return;
}
$request = $this->requestStack->getCurrentRequest();
if (null === $request) {
return;
}
$q = $request->query->get('q', '');
if (!is_string($q) || '' === trim($q)) {
return;
}
$escaped = addcslashes(trim($q), '%_');
$paramName = $queryNameGenerator->generateParameterName('searchQ');
$alias = $queryBuilder->getRootAliases()[0];
$queryBuilder
->andWhere(sprintf('LOWER(%s.name) LIKE :%s OR LOWER(%s.reference) LIKE :%s', $alias, $paramName, $alias, $paramName))
->setParameter($paramName, '%'.strtolower($escaped).'%')
;
}
}

View File

@@ -38,6 +38,9 @@ class AuditLog
#[ORM\Column(type: Types::STRING, length: 36, nullable: true)] #[ORM\Column(type: Types::STRING, length: 36, nullable: true)]
private ?string $actorProfileId = null; private ?string $actorProfileId = null;
#[ORM\Column(type: Types::INTEGER, nullable: true)]
private ?int $version = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
@@ -48,6 +51,7 @@ class AuditLog
?array $diff = null, ?array $diff = null,
?array $snapshot = null, ?array $snapshot = null,
?string $actorProfileId = null, ?string $actorProfileId = null,
?int $version = null,
) { ) {
$this->entityType = $entityType; $this->entityType = $entityType;
$this->entityId = $entityId; $this->entityId = $entityId;
@@ -55,6 +59,7 @@ class AuditLog
$this->diff = $diff; $this->diff = $diff;
$this->snapshot = $snapshot; $this->snapshot = $snapshot;
$this->actorProfileId = $actorProfileId; $this->actorProfileId = $actorProfileId;
$this->version = $version;
} }
#[ORM\PrePersist] #[ORM\PrePersist]
@@ -109,6 +114,18 @@ class AuditLog
return $this->createdAt; return $this->createdAt;
} }
public function getVersion(): ?int
{
return $this->version;
}
public function setVersion(?int $version): static
{
$this->version = $version;
return $this;
}
private function generateCuid(): string private function generateCuid(): string
{ {
// Keep the same lightweight CUID-like strategy used across the project. // Keep the same lightweight CUID-like strategy used across the project.

View File

@@ -14,6 +14,8 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use App\Entity\Trait\CuidEntityTrait; use App\Entity\Trait\CuidEntityTrait;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -79,10 +81,15 @@ class Comment
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')]
private DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
/** @var Collection<int, Document> */
#[ORM\OneToMany(targetEntity: Document::class, mappedBy: 'comment', cascade: ['remove'])]
private Collection $documents;
public function __construct() public function __construct()
{ {
$this->createdAt = new DateTimeImmutable(); $this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
$this->documents = new ArrayCollection();
} }
public function getContent(): string public function getContent(): string
@@ -204,4 +211,10 @@ class Comment
return $this; return $this;
} }
/** @return Collection<int, Document> */
public function getDocuments(): Collection
{
return $this->documents;
}
} }

View File

@@ -15,25 +15,30 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait; use App\Entity\Trait\CuidEntityTrait;
use App\Filter\MultiSearchFilter;
use App\Repository\ComposantRepository; use App\Repository\ComposantRepository;
use App\State\ComposantProcessor;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
#[UniqueEntity(fields: ['reference'], message: 'Un composant avec cette référence existe déjà.')]
#[ORM\Entity(repositoryClass: ComposantRepository::class)] #[ORM\Entity(repositoryClass: ComposantRepository::class)]
#[ORM\Table(name: 'composants')] #[ORM\Table(name: 'composants')]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact', 'typeComposant.name' => 'ipartial'])] #[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact', 'typeComposant.name' => 'ipartial'])]
#[ApiFilter(MultiSearchFilter::class, properties: ['name', 'reference'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])] #[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
#[ApiResource( #[ApiResource(
description: 'Composants du catalogue. Un composant représente un élément fonctionnel rattaché à une machine, avec un type, des fournisseurs et des documents.', description: 'Composants du catalogue. Un composant représente un élément fonctionnel rattaché à une machine, avec un type, des fournisseurs et des documents.',
operations: [ operations: [
new Get(security: "is_granted('ROLE_VIEWER')"), new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"), new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"), new Post(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ComposantProcessor::class),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"), new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"), new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"), new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
@@ -51,7 +56,7 @@ class Composant
#[Groups(['composant:read', 'document:list'])] #[Groups(['composant:read', 'document:list'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)] #[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['composant:read', 'document:list'])] #[Groups(['composant:read', 'document:list'])]
private string $name; private string $name;
@@ -59,6 +64,10 @@ class Composant
#[Groups(['composant:read'])] #[Groups(['composant:read'])]
private ?string $reference = null; private ?string $reference = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[Groups(['composant:read'])]
private ?string $referenceAuto = null;
#[ORM\Column(type: Types::TEXT, nullable: true)] #[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['composant:read'])] #[Groups(['composant:read'])]
private ?string $description = null; private ?string $description = null;
@@ -78,16 +87,11 @@ class Composant
private ?Product $product = null; private ?Product $product = null;
/** /**
* @var Collection<int, Constructeur> * @var Collection<int, ComposantConstructeurLink>
*/ */
#[ORM\ManyToMany(targetEntity: Constructeur::class, inversedBy: 'composants')] #[ORM\OneToMany(mappedBy: 'composant', targetEntity: ComposantConstructeurLink::class, cascade: ['remove'])]
#[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'])] #[Groups(['composant:read'])]
private Collection $constructeurs; private Collection $constructeurLinks;
/** /**
* @var Collection<int, Document> * @var Collection<int, Document>
@@ -130,10 +134,18 @@ class Composant
#[ORM\OrderBy(['position' => 'ASC'])] #[ORM\OrderBy(['position' => 'ASC'])]
private Collection $productSlots; private Collection $productSlots;
/**
* Transient — holds the structure payload sent by the frontend during creation.
* Not mapped to any column; consumed by ComposantProcessor.
*/
private ?array $pendingStructure = null;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])] #[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
#[Groups(['composant:read'])] #[Groups(['composant:read'])]
private int $version = 1; private int $version = 1;
private bool $skipAudit = false;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['composant:read'])] #[Groups(['composant:read'])]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
@@ -146,7 +158,7 @@ class Composant
{ {
$this->createdAt = new DateTimeImmutable(); $this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
$this->constructeurs = new ArrayCollection(); $this->constructeurLinks = new ArrayCollection();
$this->documents = new ArrayCollection(); $this->documents = new ArrayCollection();
$this->customFieldValues = new ArrayCollection(); $this->customFieldValues = new ArrayCollection();
$this->machineLinks = new ArrayCollection(); $this->machineLinks = new ArrayCollection();
@@ -179,6 +191,21 @@ class Composant
return $this; return $this;
} }
public function getReferenceAuto(): ?string
{
return $this->referenceAuto;
}
/**
* @internal used by ReferenceAutoSubscriber only — not part of the public API
*/
public function setReferenceAuto(?string $referenceAuto): static
{
$this->referenceAuto = $referenceAuto;
return $this;
}
public function getDescription(): ?string public function getDescription(): ?string
{ {
return $this->description; return $this->description;
@@ -228,43 +255,11 @@ class Composant
} }
/** /**
* @return Collection<int, Constructeur> * @return Collection<int, ComposantConstructeurLink>
*/ */
public function getConstructeurs(): Collection public function getConstructeurLinks(): Collection
{ {
return $this->constructeurs; return $this->constructeurLinks;
}
/**
* @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;
} }
/** /**
@@ -351,10 +346,12 @@ class Composant
{ {
$pieces = []; $pieces = [];
foreach ($this->pieceSlots as $slot) { foreach ($this->pieceSlots as $slot) {
$selectedPiece = $slot->getSelectedPiece();
$pieces[] = [ $pieces[] = [
'slotId' => $slot->getId(), 'slotId' => $slot->getId(),
'typePieceId' => $slot->getTypePiece()?->getId(), 'typePieceId' => $slot->getTypePiece()?->getId(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(), 'selectedPieceId' => $selectedPiece?->getId(),
'selectedPieceName' => $selectedPiece?->getName(),
'quantity' => $slot->getQuantity(), 'quantity' => $slot->getQuantity(),
'position' => $slot->getPosition(), 'position' => $slot->getPosition(),
]; ];
@@ -362,10 +359,12 @@ class Composant
$products = []; $products = [];
foreach ($this->productSlots as $slot) { foreach ($this->productSlots as $slot) {
$selectedProduct = $slot->getSelectedProduct();
$products[] = [ $products[] = [
'slotId' => $slot->getId(), 'slotId' => $slot->getId(),
'typeProductId' => $slot->getTypeProduct()?->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(), 'selectedProductId' => $selectedProduct?->getId(),
'selectedProductName' => $selectedProduct?->getName(),
'familyCode' => $slot->getFamilyCode(), 'familyCode' => $slot->getFamilyCode(),
'position' => $slot->getPosition(), 'position' => $slot->getPosition(),
]; ];
@@ -373,12 +372,14 @@ class Composant
$subcomponents = []; $subcomponents = [];
foreach ($this->subcomponentSlots as $slot) { foreach ($this->subcomponentSlots as $slot) {
$selectedComposant = $slot->getSelectedComposant();
$subcomponents[] = [ $subcomponents[] = [
'slotId' => $slot->getId(), 'slotId' => $slot->getId(),
'alias' => $slot->getAlias(), 'alias' => $slot->getAlias(),
'familyCode' => $slot->getFamilyCode(), 'familyCode' => $slot->getFamilyCode(),
'typeComposantId' => $slot->getTypeComposant()?->getId(), 'typeComposantId' => $slot->getTypeComposant()?->getId(),
'selectedComponentId' => $slot->getSelectedComposant()?->getId(), 'selectedComponentId' => $selectedComposant?->getId(),
'selectedComponentName' => $selectedComposant?->getName(),
'position' => $slot->getPosition(), 'position' => $slot->getPosition(),
]; ];
} }
@@ -394,6 +395,27 @@ class Composant
]; ];
} }
/**
* Called by API Platform during denormalization — stores the frontend
* structure payload so the ComposantProcessor can apply selections.
*/
public function setStructure(?array $structure): static
{
$this->pendingStructure = $structure;
return $this;
}
public function getPendingStructure(): ?array
{
return $this->pendingStructure;
}
public function clearPendingStructure(): void
{
$this->pendingStructure = null;
}
public function addProductSlot(ComposantProductSlot $productSlot): static public function addProductSlot(ComposantProductSlot $productSlot): static
{ {
if (!$this->productSlots->contains($productSlot)) { if (!$this->productSlots->contains($productSlot)) {
@@ -422,4 +444,16 @@ class Composant
return $this; return $this;
} }
public function getSkipAudit(): bool
{
return $this->skipAudit;
}
public function setSkipAudit(bool $skipAudit): static
{
$this->skipAudit = $skipAudit;
return $this;
}
} }

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Entity;
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\ComposantConstructeurLinkRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ComposantConstructeurLinkRepository::class)]
#[ORM\Table(name: 'composant_constructeur_links')]
#[ORM\UniqueConstraint(name: 'uniq_composant_constructeur', columns: ['composantid', 'constructeurid'])]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Liaisons composantfournisseur. Chaque liaison peut porter une référence fournisseur spécifique.',
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')"),
]
)]
#[ApiFilter(SearchFilter::class, properties: ['composant' => 'exact', 'constructeur' => 'exact'])]
class ComposantConstructeurLink
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'constructeurLinks')]
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Composant $composant;
#[ORM\ManyToOne(targetEntity: Constructeur::class, inversedBy: 'composantLinks')]
#[ORM\JoinColumn(name: 'constructeurId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Constructeur $constructeur;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'supplierReference')]
private ?string $supplierReference = 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();
}
public function getComposant(): Composant
{
return $this->composant;
}
public function setComposant(Composant $composant): static
{
$this->composant = $composant;
return $this;
}
public function getConstructeur(): Constructeur
{
return $this->constructeur;
}
public function setConstructeur(Constructeur $constructeur): static
{
$this->constructeur = $constructeur;
return $this;
}
public function getSupplierReference(): ?string
{
return $this->supplierReference;
}
public function setSupplierReference(?string $supplierReference): static
{
$this->supplierReference = $supplierReference;
return $this;
}
}

View File

@@ -63,37 +63,37 @@ class Constructeur
private DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
/** /**
* @var Collection<int, Machine> * @var Collection<int, MachineConstructeurLink>
*/ */
#[ORM\ManyToMany(targetEntity: Machine::class, mappedBy: 'constructeurs')] #[ORM\OneToMany(mappedBy: 'constructeur', targetEntity: MachineConstructeurLink::class, cascade: ['remove'])]
private Collection $machines; private Collection $machineLinks;
/** /**
* @var Collection<int, Composant> * @var Collection<int, ComposantConstructeurLink>
*/ */
#[ORM\ManyToMany(targetEntity: Composant::class, mappedBy: 'constructeurs')] #[ORM\OneToMany(mappedBy: 'constructeur', targetEntity: ComposantConstructeurLink::class, cascade: ['remove'])]
private Collection $composants; private Collection $composantLinks;
/** /**
* @var Collection<int, Piece> * @var Collection<int, PieceConstructeurLink>
*/ */
#[ORM\ManyToMany(targetEntity: Piece::class, mappedBy: 'constructeurs')] #[ORM\OneToMany(mappedBy: 'constructeur', targetEntity: PieceConstructeurLink::class, cascade: ['remove'])]
private Collection $pieces; private Collection $pieceLinks;
/** /**
* @var Collection<int, Product> * @var Collection<int, ProductConstructeurLink>
*/ */
#[ORM\ManyToMany(targetEntity: Product::class, mappedBy: 'constructeurs')] #[ORM\OneToMany(mappedBy: 'constructeur', targetEntity: ProductConstructeurLink::class, cascade: ['remove'])]
private Collection $products; private Collection $productLinks;
public function __construct() public function __construct()
{ {
$this->createdAt = new DateTimeImmutable(); $this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
$this->machines = new ArrayCollection(); $this->machineLinks = new ArrayCollection();
$this->composants = new ArrayCollection(); $this->composantLinks = new ArrayCollection();
$this->pieces = new ArrayCollection(); $this->pieceLinks = new ArrayCollection();
$this->products = new ArrayCollection(); $this->productLinks = new ArrayCollection();
} }
public function getName(): ?string public function getName(): ?string

View File

@@ -12,9 +12,11 @@ use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait; use App\Entity\Trait\CuidEntityTrait;
use App\Enum\DocumentType;
use App\Repository\DocumentRepository; use App\Repository\DocumentRepository;
use App\State\DocumentUploadProcessor; use App\State\DocumentUploadProcessor;
use DateTimeImmutable; use DateTimeImmutable;
@@ -26,7 +28,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Table(name: 'documents')] #[ORM\Table(name: 'documents')]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'filename' => 'ipartial'])] #[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'filename' => 'ipartial'])]
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product'])] #[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product', 'comment'])]
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])] #[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])]
#[ApiResource( #[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.', 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.',
@@ -46,6 +48,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
inputFormats: ['multipart' => ['multipart/form-data']], inputFormats: ['multipart' => ['multipart/form-data']],
), ),
new Put(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')"), new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
], ],
paginationClientItemsPerPage: true, paginationClientItemsPerPage: true,
@@ -105,6 +108,15 @@ class Document
#[Groups(['document:list'])] #[Groups(['document:list'])]
private ?Site $site = null; private ?Site $site = null;
#[ORM\ManyToOne(targetEntity: Comment::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'comment_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Comment $comment = null;
#[ORM\Column(type: Types::STRING, length: 20, enumType: DocumentType::class)]
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private DocumentType $type = DocumentType::DOCUMENTATION;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['document:list'])] #[Groups(['document:list'])]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
@@ -237,4 +249,28 @@ class Document
return $this; return $this;
} }
public function getType(): DocumentType
{
return $this->type;
}
public function setType(DocumentType $type): static
{
$this->type = $type;
return $this;
}
public function getComment(): ?Comment
{
return $this->comment;
}
public function setComment(?Comment $comment): static
{
$this->comment = $comment;
return $this;
}
} }

View File

@@ -18,12 +18,14 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: MachineRepository::class)] #[ORM\Entity(repositoryClass: MachineRepository::class)]
#[ORM\Table(name: 'machines')] #[ORM\Table(name: 'machines')]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.')]
#[ApiResource( #[ApiResource(
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.', 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: [ operations: [
@@ -59,15 +61,10 @@ class Machine
private ?Site $site = null; private ?Site $site = null;
/** /**
* @var Collection<int, Constructeur> * @var Collection<int, MachineConstructeurLink>
*/ */
#[ORM\ManyToMany(targetEntity: Constructeur::class, inversedBy: 'machines')] #[ORM\OneToMany(mappedBy: 'machine', targetEntity: MachineConstructeurLink::class, cascade: ['remove'])]
#[ORM\JoinTable( private Collection $constructeurLinks;
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> * @var Collection<int, MachineComponentLink>
@@ -108,6 +105,15 @@ class Machine
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
private int $version = 1;
/**
* Transient flag — when true, audit subscribers skip this entity.
* Used by EntityVersionService::restore() to avoid duplicate AuditLogs.
*/
private bool $skipAudit = false;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
@@ -116,7 +122,7 @@ class Machine
$now = new DateTimeImmutable(); $now = new DateTimeImmutable();
$this->createdAt = $now; $this->createdAt = $now;
$this->updatedAt = $now; $this->updatedAt = $now;
$this->constructeurs = new ArrayCollection(); $this->constructeurLinks = new ArrayCollection();
$this->componentLinks = new ArrayCollection(); $this->componentLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection(); $this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection(); $this->productLinks = new ArrayCollection();
@@ -203,27 +209,11 @@ class Machine
} }
/** /**
* @return Collection<int, Constructeur> * @return Collection<int, MachineConstructeurLink>
*/ */
public function getConstructeurs(): Collection public function getConstructeurLinks(): Collection
{ {
return $this->constructeurs; return $this->constructeurLinks;
}
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;
} }
/** /**
@@ -265,4 +255,28 @@ class Machine
{ {
return $this->customFieldValues; return $this->customFieldValues;
} }
public function getVersion(): int
{
return $this->version;
}
public function incrementVersion(): static
{
++$this->version;
return $this;
}
public function getSkipAudit(): bool
{
return $this->skipAudit;
}
public function setSkipAudit(bool $skipAudit): static
{
$this->skipAudit = $skipAudit;
return $this;
}
} }

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Entity;
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\MachineConstructeurLinkRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: MachineConstructeurLinkRepository::class)]
#[ORM\Table(name: 'machine_constructeur_links')]
#[ORM\UniqueConstraint(name: 'uniq_machine_constructeur', columns: ['machineid', 'constructeurid'])]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Liaisons machinefournisseur. Chaque liaison peut porter une référence fournisseur spécifique.',
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')"),
]
)]
#[ApiFilter(SearchFilter::class, properties: ['machine' => 'exact', 'constructeur' => 'exact'])]
class MachineConstructeurLink
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'constructeurLinks')]
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Machine $machine;
#[ORM\ManyToOne(targetEntity: Constructeur::class, inversedBy: 'machineLinks')]
#[ORM\JoinColumn(name: 'constructeurId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Constructeur $constructeur;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'supplierReference')]
private ?string $supplierReference = 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();
}
public function getMachine(): Machine
{
return $this->machine;
}
public function setMachine(Machine $machine): static
{
$this->machine = $machine;
return $this;
}
public function getConstructeur(): Constructeur
{
return $this->constructeur;
}
public function setConstructeur(Constructeur $constructeur): static
{
$this->constructeur = $constructeur;
return $this;
}
public function getSupplierReference(): ?string
{
return $this->supplierReference;
}
public function setSupplierReference(?string $supplierReference): static
{
$this->supplierReference = $supplierReference;
return $this;
}
}

View File

@@ -73,6 +73,14 @@ class ModelType
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])] #[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])]
private ?string $description = null; private ?string $description = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['model_type:read', 'model_type:write'])]
private ?string $referenceFormula = null;
#[ORM\Column(type: Types::JSON, nullable: true)]
#[Groups(['model_type:read', 'model_type:write'])]
private ?array $requiredFieldsForReference = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['model_type:read'])] #[Groups(['model_type:read'])]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
@@ -215,6 +223,30 @@ class ModelType
return $this; return $this;
} }
public function getReferenceFormula(): ?string
{
return $this->referenceFormula;
}
public function setReferenceFormula(?string $referenceFormula): static
{
$this->referenceFormula = $referenceFormula;
return $this;
}
public function getRequiredFieldsForReference(): ?array
{
return $this->requiredFieldsForReference;
}
public function setRequiredFieldsForReference(?array $requiredFieldsForReference): static
{
$this->requiredFieldsForReference = $requiredFieldsForReference;
return $this;
}
#[Groups(['model_type:read', 'product:read', 'composant:read', 'piece:read'])] #[Groups(['model_type:read', 'product:read', 'composant:read', 'piece:read'])]
public function getStructure(): ?array public function getStructure(): ?array
{ {

View File

@@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait; use App\Entity\Trait\CuidEntityTrait;
use App\Filter\MultiSearchFilter;
use App\Repository\PieceRepository; use App\Repository\PieceRepository;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -29,6 +30,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Table(name: 'pieces')] #[ORM\Table(name: 'pieces')]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact', 'typePiece.name' => 'ipartial'])] #[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact', 'typePiece.name' => 'ipartial'])]
#[ApiFilter(MultiSearchFilter::class, properties: ['name', 'reference'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])] #[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
#[ApiResource( #[ApiResource(
description: 'Pièces détachées du catalogue. Une pièce peut être rattachée à plusieurs machines et possède un type, des fournisseurs, des documents et un produit associé.', description: 'Pièces détachées du catalogue. Une pièce peut être rattachée à plusieurs machines et possède un type, des fournisseurs, des documents et un produit associé.',
@@ -61,6 +63,10 @@ class Piece
#[Groups(['piece:read'])] #[Groups(['piece:read'])]
private ?string $reference = null; private ?string $reference = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[Groups(['piece:read'])]
private ?string $referenceAuto = null;
#[ORM\Column(type: Types::TEXT, nullable: true)] #[ORM\Column(type: Types::TEXT, nullable: true)]
#[Groups(['piece:read'])] #[Groups(['piece:read'])]
private ?string $description = null; private ?string $description = null;
@@ -80,16 +86,11 @@ class Piece
private ?Product $product = null; private ?Product $product = null;
/** /**
* @var Collection<int, Constructeur> * @var Collection<int, PieceConstructeurLink>
*/ */
#[ORM\ManyToMany(targetEntity: Constructeur::class, inversedBy: 'pieces')] #[ORM\OneToMany(mappedBy: 'piece', targetEntity: PieceConstructeurLink::class, cascade: ['remove'])]
#[ORM\JoinTable(
name: '_PieceConstructeurs',
joinColumns: [new ORM\JoinColumn(name: 'A', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\InverseJoinColumn(name: 'B', referencedColumnName: 'id', onDelete: 'CASCADE')]
)]
#[Groups(['piece:read'])] #[Groups(['piece:read'])]
private Collection $constructeurs; private Collection $constructeurLinks;
/** /**
* @var Collection<int, Document> * @var Collection<int, Document>
@@ -131,6 +132,8 @@ class Piece
#[Groups(['piece:read'])] #[Groups(['piece:read'])]
private int $version = 1; private int $version = 1;
private bool $skipAudit = false;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['piece:read'])] #[Groups(['piece:read'])]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
@@ -143,7 +146,7 @@ class Piece
{ {
$this->createdAt = new DateTimeImmutable(); $this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
$this->constructeurs = new ArrayCollection(); $this->constructeurLinks = new ArrayCollection();
$this->documents = new ArrayCollection(); $this->documents = new ArrayCollection();
$this->customFieldValues = new ArrayCollection(); $this->customFieldValues = new ArrayCollection();
$this->products = new ArrayCollection(); $this->products = new ArrayCollection();
@@ -175,6 +178,21 @@ class Piece
return $this; return $this;
} }
public function getReferenceAuto(): ?string
{
return $this->referenceAuto;
}
/**
* @internal used by ReferenceAutoSubscriber only — not part of the public API
*/
public function setReferenceAuto(?string $referenceAuto): static
{
$this->referenceAuto = $referenceAuto;
return $this;
}
public function getDescription(): ?string public function getDescription(): ?string
{ {
return $this->description; return $this->description;
@@ -237,27 +255,11 @@ class Piece
} }
/** /**
* @return Collection<int, Constructeur> * @return Collection<int, PieceConstructeurLink>
*/ */
public function getConstructeurs(): Collection public function getConstructeurLinks(): Collection
{ {
return $this->constructeurs; return $this->constructeurLinks;
}
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;
} }
/** /**
@@ -336,4 +338,16 @@ class Piece
return $this; return $this;
} }
public function getSkipAudit(): bool
{
return $this->skipAudit;
}
public function setSkipAudit(bool $skipAudit): static
{
$this->skipAudit = $skipAudit;
return $this;
}
} }

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Entity;
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\PieceConstructeurLinkRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: PieceConstructeurLinkRepository::class)]
#[ORM\Table(name: 'piece_constructeur_links')]
#[ORM\UniqueConstraint(name: 'uniq_piece_constructeur', columns: ['pieceid', 'constructeurid'])]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Liaisons piècefournisseur. Chaque liaison peut porter une référence fournisseur spécifique.',
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')"),
]
)]
#[ApiFilter(SearchFilter::class, properties: ['piece' => 'exact', 'constructeur' => 'exact'])]
class PieceConstructeurLink
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
private ?string $id = null;
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'constructeurLinks')]
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Piece $piece;
#[ORM\ManyToOne(targetEntity: Constructeur::class, inversedBy: 'pieceLinks')]
#[ORM\JoinColumn(name: 'constructeurId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private Constructeur $constructeur;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'supplierReference')]
private ?string $supplierReference = 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();
}
public function getPiece(): Piece
{
return $this->piece;
}
public function setPiece(Piece $piece): static
{
$this->piece = $piece;
return $this;
}
public function getConstructeur(): Constructeur
{
return $this->constructeur;
}
public function setConstructeur(Constructeur $constructeur): static
{
$this->constructeur = $constructeur;
return $this;
}
public function getSupplierReference(): ?string
{
return $this->supplierReference;
}
public function setSupplierReference(?string $supplierReference): static
{
$this->supplierReference = $supplierReference;
return $this;
}
}

View File

@@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait; use App\Entity\Trait\CuidEntityTrait;
use App\Filter\MultiSearchFilter;
use App\Repository\ProductRepository; use App\Repository\ProductRepository;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -27,6 +28,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Table(name: 'products')] #[ORM\Table(name: 'products')]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact', 'typeProduct.name' => 'ipartial'])] #[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact', 'typeProduct.name' => 'ipartial'])]
#[ApiFilter(MultiSearchFilter::class, properties: ['name', 'reference'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt', 'supplierPrice'])] #[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt', 'supplierPrice'])]
#[ApiResource( #[ApiResource(
description: 'Produits du catalogue fournisseur. Un produit possède une référence, un prix indicatif, un type, des fournisseurs et des documents. Il peut être lié à des machines.', description: 'Produits du catalogue fournisseur. Un produit possède une référence, un prix indicatif, un type, des fournisseurs et des documents. Il peut être lié à des machines.',
@@ -69,16 +71,11 @@ class Product
private ?ModelType $typeProduct = null; private ?ModelType $typeProduct = null;
/** /**
* @var Collection<int, Constructeur> * @var Collection<int, ProductConstructeurLink>
*/ */
#[ORM\ManyToMany(targetEntity: Constructeur::class, inversedBy: 'products')] #[ORM\OneToMany(mappedBy: 'product', targetEntity: ProductConstructeurLink::class, cascade: ['remove'])]
#[ORM\JoinTable(
name: '_ProductConstructeurs',
joinColumns: [new ORM\JoinColumn(name: 'A', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\InverseJoinColumn(name: 'B', referencedColumnName: 'id', onDelete: 'CASCADE')]
)]
#[Groups(['product:read'])] #[Groups(['product:read'])]
private Collection $constructeurs; private Collection $constructeurLinks;
/** /**
* @var Collection<int, Document> * @var Collection<int, Document>
@@ -122,6 +119,8 @@ class Product
#[Groups(['product:read'])] #[Groups(['product:read'])]
private int $version = 1; private int $version = 1;
private bool $skipAudit = false;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['product:read'])] #[Groups(['product:read'])]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
@@ -134,7 +133,7 @@ class Product
{ {
$this->createdAt = new DateTimeImmutable(); $this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
$this->constructeurs = new ArrayCollection(); $this->constructeurLinks = new ArrayCollection();
$this->documents = new ArrayCollection(); $this->documents = new ArrayCollection();
$this->customFieldValues = new ArrayCollection(); $this->customFieldValues = new ArrayCollection();
$this->pieces = new ArrayCollection(); $this->pieces = new ArrayCollection();
@@ -192,27 +191,11 @@ class Product
} }
/** /**
* @return Collection<int, Constructeur> * @return Collection<int, ProductConstructeurLink>
*/ */
public function getConstructeurs(): Collection public function getConstructeurLinks(): Collection
{ {
return $this->constructeurs; return $this->constructeurLinks;
}
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;
} }
/** /**
@@ -250,4 +233,16 @@ class Product
return $this; return $this;
} }
public function getSkipAudit(): bool
{
return $this->skipAudit;
}
public function setSkipAudit(bool $skipAudit): static
{
$this->skipAudit = $skipAudit;
return $this;
}
} }

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