Compare commits
54 Commits
feature/SI
...
f965affc94
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f965affc94 | ||
|
|
4340a0e13e | ||
|
|
bd7259ed05 | ||
|
|
2f173e766d | ||
|
|
4f1e136dc5 | ||
|
|
e335f4c24c | ||
|
|
46ea3ca8ad | ||
|
|
65fbd38b55 | ||
|
|
37aa755819 | ||
|
|
98caaa148d | ||
|
|
523eed927e | ||
|
|
43bec07bb8 | ||
|
|
0181f18778 | ||
|
|
8e0acf4896 | ||
|
|
aa8e043c83 | ||
|
|
b2aff0e414 | ||
|
|
4072abf7ba | ||
|
|
089ca43404 | ||
|
|
f09c7e4782 | ||
|
|
6a20dcce54 | ||
|
|
6e0be3dbf3 | ||
|
|
f66db3f2f0 | ||
|
|
0864af1439 | ||
|
|
5210e53d73 | ||
|
|
3f07162b94 | ||
|
|
57615b3e9d | ||
|
|
46694d11d9 | ||
|
|
44cfa25eca | ||
|
|
7ea4cc8c12 | ||
|
|
bb300a7ca7 | ||
|
|
556da6e451 | ||
|
|
8871440c9a | ||
|
|
6f1756e82e | ||
|
|
55bed90ac7 | ||
|
|
a6139d7090 | ||
|
|
8ed5f90b63 | ||
|
|
5194543d16 | ||
|
|
c01b71fe06 | ||
|
|
5336dfc09d | ||
|
|
77c5d25cea | ||
|
|
e2326064ba | ||
|
|
100e24725c | ||
|
|
515bae189e | ||
|
|
333f2a88af | ||
|
|
eccbc1bd56 | ||
|
|
2a0809a065 | ||
|
|
f2061abce8 | ||
|
|
42c7072bcd | ||
|
|
1f90f809ac | ||
|
|
a940f53f8a | ||
|
|
c74bdedf9b | ||
|
|
233ee3faf3 | ||
|
|
b8edf1ea95 | ||
|
|
7a7af58074 |
14
.mcp.json
Normal file
14
.mcp.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"inventory": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"exec", "-i",
|
||||
"-e", "MCP_PROFILE_ID=REPLACE_WITH_YOUR_PROFILE_ID",
|
||||
"-e", "MCP_PROFILE_PASSWORD=REPLACE_WITH_YOUR_PASSWORD",
|
||||
"php-inventory-apache",
|
||||
"php", "bin/console", "mcp:server"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
38
CLAUDE.md
38
CLAUDE.md
@@ -64,6 +64,14 @@ npm run build # Build production
|
||||
npm run lint:fix # ESLint fix
|
||||
npx nuxi typecheck # TypeScript check (0 errors attendu)
|
||||
|
||||
# Database / Fixtures
|
||||
make db-reset # Reset database (drop + recreate schema)
|
||||
make fixtures-dump # Dump la DB vers fixtures/data.sql
|
||||
make fixtures-load # Charger les fixtures SQL (désactive FK)
|
||||
make fixtures-reset # Reset DB + recharger fixtures
|
||||
make import-data # Importer les dumps SQL normalisés
|
||||
make cache-clear # Clear cache Symfony
|
||||
|
||||
# Release
|
||||
./scripts/release.sh patch # Bump patch version (ou minor/major)
|
||||
```
|
||||
@@ -101,6 +109,11 @@ Le frontend est un submodule git. Lors d'un commit frontend :
|
||||
### Entités Principales
|
||||
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
|
||||
|
||||
#### Entités de normalisation (slots & skeleton requirements)
|
||||
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
|
||||
- **Slots** (données réelles d'un composant) : `ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot`
|
||||
- **Skeleton Requirements** (définitions du ModelType) : `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
|
||||
|
||||
### Patterns
|
||||
- **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
|
||||
- **ORM** : Attributs PHP 8 (`#[ORM\Column(...)]`, `#[Groups([...])]`)
|
||||
@@ -110,15 +123,32 @@ Le frontend est un submodule git. Lors d'un commit frontend :
|
||||
- **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence
|
||||
|
||||
### Custom Controllers (pas API Platform)
|
||||
- `MachineStructureController` — `/api/machines/{id}/structure` (GET/PATCH) : hiérarchie complète machine avec normalisation JSON manuelle (pas Symfony Serializer). Source principale de données pour la page détail machine.
|
||||
- `MachineStructureController` — `/api/machines/{id}/structure` (GET/PATCH), `/api/machines/{id}/clone` (POST) : hiérarchie complète machine avec normalisation JSON manuelle. Source principale de données pour la page détail machine.
|
||||
- `MachineCustomFieldsController` — `/api/machines/{id}/add-custom-fields` (POST) : initialise les CustomFieldValue manquants pour une machine.
|
||||
- `CustomFieldValueController` — `/api/custom-fields/values/*` : CRUD + upsert pour les valeurs de champs perso.
|
||||
- `ComposantPieceSlotController` — `/api/composant-piece-slots/{id}` (PATCH) : mise à jour des slots pièce d'un composant.
|
||||
- `SessionProfileController` — `/api/session/profile` (GET/POST/DELETE) : auth session (login/logout/current user).
|
||||
- `SessionProfilesController` — `/api/session/profiles` (GET) : liste des profils disponibles pour la session.
|
||||
- `AdminProfileController` — `/api/admin/profiles` : CRUD profils, gestion rôles et mots de passe (ROLE_ADMIN).
|
||||
- `CommentController` — `/api/comments` : création, résolution, compteur non-résolus.
|
||||
- `ActivityLogController` — `/api/activity-logs` (GET) : journal d'activité global.
|
||||
- `EntityHistoryController` — `/api/{entity}/{id}/history` (GET) : historique audit par entité (machines, pièces, composants, produits).
|
||||
- `DocumentQueryController` — `/api/documents/{entity}/{id}` (GET) : documents par site/machine/composant/pièce/produit.
|
||||
- `DocumentServeController` — `/api/documents/{id}/file|download` (GET) : servir/télécharger fichiers.
|
||||
- `ModelTypeConversionController` — `/api/model_types/{id}/conversion-check|convert` : vérification et conversion de ModelType.
|
||||
- `HealthCheckController` — `/api/health` (GET) : health check.
|
||||
|
||||
### Custom Fields — Architecture
|
||||
- **Composants/Pièces/Produits** : définitions dans le JSON `structure` du ModelType
|
||||
- **Composants/Pièces/Produits** : définitions dans les entités `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` du ModelType (anciennement JSON `structure`, normalisé en tables relationnelles). Les custom fields de ces entités sont définis dans `customFields` JSON sur chaque Skeleton*Requirement.
|
||||
- **Machines** : définitions = entités `CustomField` liées directement via `machineId` FK (pas de ModelType)
|
||||
- Les deux partagent la même entité `CustomFieldValue` pour stocker les valeurs
|
||||
|
||||
### Normalisation JSON → Tables (architecture slots)
|
||||
Les anciennes colonnes JSON `structure` et `productIds` des Composants ont été remplacées par des tables relationnelles :
|
||||
- **ModelType** définit le squelette via `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
|
||||
- **Composant** stocke les données réelles via `ComposantPieceSlot`, `ComposantProductSlot`, `ComposantSubcomponentSlot`
|
||||
- Chaque slot référence son skeleton requirement (`skeletonRequirement` FK) + l'entité sélectionnée + position
|
||||
|
||||
### Rôles (hiérarchie)
|
||||
```
|
||||
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||
@@ -193,8 +223,8 @@ make test-setup # Créer/mettre à jour le schéma test
|
||||
### Pattern de test
|
||||
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
|
||||
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
|
||||
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`
|
||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`
|
||||
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`
|
||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
|
||||
|
||||
## URLs Locales
|
||||
- API Symfony : `http://localhost:8081/api`
|
||||
|
||||
Submodule Inventory_frontend updated: 5c31045e83...d4fc0f1fee
@@ -1,4 +1,4 @@
|
||||
# InventoryTEST
|
||||
# Inventory
|
||||
|
||||
Application de gestion d'inventaire industriel pour **Malio**. Gestion complète du parc machines, des pièces, composants, produits, fournisseurs et documents associés, avec traçabilité et contrôle d'accès par rôles.
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"doctrine/orm": "^3.6",
|
||||
"lexik/jwt-authentication-bundle": "^3.2",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"symfony/asset": "8.0.*",
|
||||
@@ -22,8 +23,10 @@
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/mcp-bundle": "^0.6.0",
|
||||
"symfony/property-access": "8.0.*",
|
||||
"symfony/property-info": "8.0.*",
|
||||
"symfony/rate-limiter": "8.0.*",
|
||||
"symfony/runtime": "8.0.*",
|
||||
"symfony/security-bundle": "8.0.*",
|
||||
"symfony/serializer": "8.0.*",
|
||||
|
||||
1033
composer.lock
generated
1033
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
|
||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||
use Symfony\AI\McpBundle\McpBundle;
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||
@@ -22,4 +23,5 @@ return [
|
||||
ApiPlatformBundle::class => ['all' => true],
|
||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||
DAMADoctrineTestBundle::class => ['test' => true],
|
||||
McpBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
api_platform:
|
||||
title: Inventory API
|
||||
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
||||
version: 1.8.1
|
||||
version: 1.9.1
|
||||
defaults:
|
||||
stateless: false
|
||||
cache_headers:
|
||||
|
||||
10
config/packages/http_discovery.yaml
Normal file
10
config/packages/http_discovery.yaml
Normal 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
|
||||
20
config/packages/mcp.yaml
Normal file
20
config/packages/mcp.yaml
Normal 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
|
||||
20
config/packages/mcp.yaml.disabled
Normal file
20
config/packages/mcp.yaml.disabled
Normal 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
|
||||
6
config/packages/rate_limiter.yaml
Normal file
6
config/packages/rate_limiter.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
framework:
|
||||
rate_limiter:
|
||||
mcp_auth:
|
||||
policy: sliding_window
|
||||
limit: 5
|
||||
interval: '1 minute'
|
||||
6
config/packages/rate_limiter.yaml.disabled
Normal file
6
config/packages/rate_limiter.yaml.disabled
Normal file
@@ -0,0 +1,6 @@
|
||||
framework:
|
||||
rate_limiter:
|
||||
mcp_auth:
|
||||
policy: sliding_window
|
||||
limit: 5
|
||||
interval: '1 minute'
|
||||
@@ -27,6 +27,12 @@ security:
|
||||
pattern: ^/api/session/profiles?$
|
||||
security: false
|
||||
|
||||
mcp:
|
||||
pattern: ^/_mcp
|
||||
stateless: true
|
||||
custom_authenticators:
|
||||
- App\Mcp\Security\McpHeaderAuthenticator
|
||||
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: false
|
||||
@@ -49,6 +55,7 @@ security:
|
||||
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/_mcp, roles: ROLE_USER }
|
||||
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
|
||||
|
||||
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||
@@ -1385,7 +1387,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* mercure?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* hub_url?: scalar|null|Param, // The URL sent in the Link HTTP header. If not set, will default to the URL for MercureBundle's default hub. // Default: null
|
||||
* include_type?: bool|Param, // Always include @type in updates (including delete ones). // Default: false
|
||||
* include_type?: bool|Param, // Always include @var in updates (including delete ones). // Default: false
|
||||
* },
|
||||
* messenger?: bool|array{
|
||||
* enabled?: bool|Param, // Default: false
|
||||
@@ -1606,6 +1608,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* cache?: scalar|null|Param, // Storage to track blocked tokens // Default: "cache.app"
|
||||
* },
|
||||
* }
|
||||
* @psalm-type DamaDoctrineTestConfig = array{
|
||||
* enable_static_connection?: mixed, // Default: true
|
||||
* enable_static_meta_data_cache?: bool|Param, // Default: true
|
||||
* enable_static_query_cache?: bool|Param, // Default: true
|
||||
* connection_keys?: list<mixed>,
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1656,6 +1664,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* nelmio_cors?: NelmioCorsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* dama_doctrine_test?: DamaDoctrineTestConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
|
||||
@@ -12,3 +12,7 @@ api_login_check:
|
||||
|
||||
controllers:
|
||||
resource: routing.controllers
|
||||
|
||||
mcp:
|
||||
resource: .
|
||||
type: mcp
|
||||
|
||||
@@ -34,7 +34,33 @@ services:
|
||||
tags:
|
||||
- { name: doctrine.event_subscriber }
|
||||
|
||||
App\Mcp\Security\McpHeaderAuthenticator:
|
||||
arguments:
|
||||
$mcpAuthLimiter: '@limiter.mcp_auth'
|
||||
|
||||
App\OpenApi\OpenApiDecorator:
|
||||
decorates: 'api_platform.openapi.factory'
|
||||
arguments:
|
||||
$decorated: '@.inner'
|
||||
|
||||
when@test:
|
||||
services:
|
||||
App\Service\Sync\ProductSyncStrategy:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\Sync\ComposantSyncStrategy:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\Sync\PieceSyncStrategy:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\ModelTypeSyncService:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
185
docs/mcp/README.md
Normal file
185
docs/mcp/README.md
Normal 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 |
|
||||
1067
docs/superpowers/plans/2026-03-12-json-to-tables-normalization.md
Normal file
1067
docs/superpowers/plans/2026-03-12-json-to-tables-normalization.md
Normal file
File diff suppressed because it is too large
Load Diff
546
docs/superpowers/plans/2026-03-12-piece-quantity.md
Normal file
546
docs/superpowers/plans/2026-03-12-piece-quantity.md
Normal file
@@ -0,0 +1,546 @@
|
||||
# Piece Quantity Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a quantity field to pieces — on `MachinePieceLink` for machine-direct pieces, and in `Composant.structure.pieces[]` JSON for composant pieces.
|
||||
|
||||
**Architecture:** Quantity lives on the relationship, not the catalogue entity. For machine-direct pieces, a new `quantity` integer column on `MachinePieceLink` (default 1). For composant pieces, a `quantity` key in the existing `structure.pieces[]` JSON (default 1). Display: "×N" after piece name, hidden when N=1.
|
||||
|
||||
**Tech Stack:** Symfony 8 / API Platform, Doctrine ORM, PostgreSQL, Nuxt 4, Vue 3 Composition API, TypeScript, DaisyUI 5
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-03-12-piece-quantity-design.md`
|
||||
|
||||
---
|
||||
|
||||
## Chunk 1: Backend
|
||||
|
||||
### Task 1: Entity + Migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/MachinePieceLink.php`
|
||||
- Existing: `migrations/Version20260309150000.php` (already written, untracked)
|
||||
|
||||
- [ ] **Step 1: Add quantity field to MachinePieceLink entity**
|
||||
|
||||
In `src/Entity/MachinePieceLink.php`, add after the `prixOverride` field (line 69):
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Assert\GreaterThanOrEqual(1)]
|
||||
private int $quantity = 1;
|
||||
```
|
||||
|
||||
Add the import at the top if not present:
|
||||
```php
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
```
|
||||
|
||||
Add getter/setter after existing methods (before closing brace):
|
||||
|
||||
```php
|
||||
public function getQuantity(): int
|
||||
{
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function setQuantity(int $quantity): static
|
||||
{
|
||||
$this->quantity = $quantity;
|
||||
|
||||
return $this;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Stage the migration file**
|
||||
|
||||
The migration `migrations/Version20260309150000.php` already exists (untracked). Verify its content matches:
|
||||
|
||||
```php
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE machine_piece_links ADD COLUMN IF NOT EXISTS quantity INTEGER NOT NULL DEFAULT 1');
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run php-cs-fixer**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
|
||||
- [ ] **Step 4: Run tests to verify nothing is broken**
|
||||
|
||||
Run: `make test`
|
||||
Expected: All 167 tests pass (OK, with possible deprecation warnings)
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Entity/MachinePieceLink.php migrations/Version20260309150000.php
|
||||
git commit -m "feat(piece) : add quantity field to MachinePieceLink entity + migration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: MachineStructureController — Normalization + PATCH + Clone
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Controller/MachineStructureController.php`
|
||||
|
||||
- [ ] **Step 1: Add quantity to `normalizePieceLinks()`**
|
||||
|
||||
In `src/Controller/MachineStructureController.php`, method `normalizePieceLinks()` (line ~623-641).
|
||||
|
||||
Add `'quantity'` to the returned array, after `'overrides'`:
|
||||
|
||||
```php
|
||||
'quantity' => $this->resolvePieceQuantity($link),
|
||||
```
|
||||
|
||||
Add a new private method after `normalizePieceLinks()`:
|
||||
|
||||
```php
|
||||
private function resolvePieceQuantity(MachinePieceLink $link): int
|
||||
{
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
if (!$parentLink) {
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
$composant = $parentLink->getComposant();
|
||||
$structure = $composant->getStructure();
|
||||
|
||||
if (!is_array($structure) || !isset($structure['pieces']) || !is_array($structure['pieces'])) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$piece = $link->getPiece();
|
||||
$typePiece = $piece->getTypePiece();
|
||||
$typePieceId = $typePiece?->getId();
|
||||
|
||||
foreach ($structure['pieces'] as $pieceDef) {
|
||||
if (!is_array($pieceDef)) {
|
||||
continue;
|
||||
}
|
||||
if (isset($pieceDef['typePieceId']) && $pieceDef['typePieceId'] === $typePieceId) {
|
||||
return (int) ($pieceDef['quantity'] ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Matching is done by `typePieceId`. If a composant has two pieces of the same type, they will get the same quantity (first match). This is an acceptable limitation for now — duplicates of the same piece type in a composant are rare.
|
||||
|
||||
- [ ] **Step 2: Apply quantity in `applyPieceLinks()`**
|
||||
|
||||
In method `applyPieceLinks()` (line ~366-422), add quantity application after `$this->applyOverrides($link, $entry['overrides'] ?? null);` (line ~396):
|
||||
|
||||
```php
|
||||
if (!isset($entry['parentComponentLinkId']) && !isset($entry['parentLinkId'])) {
|
||||
$quantity = isset($entry['quantity']) ? (int) $entry['quantity'] : $link->getQuantity();
|
||||
$link->setQuantity(max(1, $quantity));
|
||||
}
|
||||
```
|
||||
|
||||
**Key behavior:**
|
||||
- Only applies to direct machine pieces (no parent component link)
|
||||
- If `quantity` not in payload: preserves existing value
|
||||
- If `quantity` in payload: sets it, with floor of 1
|
||||
- For composant-child pieces: quantity is ignored (comes from composant structure)
|
||||
|
||||
- [ ] **Step 3: Copy quantity in `clonePieceLinks()`**
|
||||
|
||||
In method `clonePieceLinks()` (line ~233-256), add after `$newLink->setPrixOverride($link->getPrixOverride());` (line ~244):
|
||||
|
||||
```php
|
||||
$newLink->setQuantity($link->getQuantity());
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run php-cs-fixer**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
|
||||
- [ ] **Step 5: Run tests**
|
||||
|
||||
Run: `make test`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Controller/MachineStructureController.php
|
||||
git commit -m "feat(piece) : add quantity to structure normalization, PATCH and clone"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Backend Tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/Api/Entity/MachinePieceLinkTest.php`
|
||||
- Modify: `tests/AbstractApiTestCase.php` (factory method)
|
||||
|
||||
- [ ] **Step 1: Update factory method to support quantity**
|
||||
|
||||
In `tests/AbstractApiTestCase.php`, update `createMachinePieceLink()` to accept an optional quantity parameter:
|
||||
|
||||
```php
|
||||
protected function createMachinePieceLink(Machine $machine, Piece $piece, ?MachineComponentLink $parentLink = null, int $quantity = 1): MachinePieceLink
|
||||
{
|
||||
$link = new MachinePieceLink();
|
||||
$link->setMachine($machine);
|
||||
$link->setPiece($piece);
|
||||
$link->setQuantity($quantity);
|
||||
if (null !== $parentLink) {
|
||||
$link->setParentLink($parentLink);
|
||||
}
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($link);
|
||||
$em->flush();
|
||||
|
||||
return $link;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add test for POST with explicit quantity**
|
||||
|
||||
In `tests/Api/Entity/MachinePieceLinkTest.php`, add. Follow the existing test pattern — use `$this->assertJsonContains()` and include the `headers` key with `Content-Type`:
|
||||
|
||||
```php
|
||||
public function testPostWithQuantity(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
$machine = $this->createMachine();
|
||||
$piece = $this->createPiece();
|
||||
|
||||
$client->request('POST', '/api/machine_piece_links', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'machine' => '/api/machines/' . $machine->getId(),
|
||||
'piece' => '/api/pieces/' . $piece->getId(),
|
||||
'quantity' => 5,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$this->assertJsonContains(['quantity' => 5]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add test for POST without quantity (default = 1)**
|
||||
|
||||
```php
|
||||
public function testPostDefaultQuantity(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
$machine = $this->createMachine();
|
||||
$piece = $this->createPiece();
|
||||
|
||||
$client->request('POST', '/api/machine_piece_links', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'machine' => '/api/machines/' . $machine->getId(),
|
||||
'piece' => '/api/pieces/' . $piece->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$this->assertJsonContains(['quantity' => 1]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests**
|
||||
|
||||
Run: `make test`
|
||||
Expected: All tests pass including new ones
|
||||
|
||||
- [ ] **Step 5: Run php-cs-fixer**
|
||||
|
||||
Run: `make php-cs-fixer-allow-risky`
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/Api/Entity/MachinePieceLinkTest.php tests/AbstractApiTestCase.php
|
||||
git commit -m "test(piece) : add quantity tests for MachinePieceLink"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chunk 2: Frontend
|
||||
|
||||
### Task 4: TypeScript Types + Sanitization + Hydration Functions
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/shared/types/inventory.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/model/componentStructure.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/model/componentStructureSanitize.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/model/componentStructureHydrate.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/utils/structureAssignmentHelpers.ts`
|
||||
|
||||
- [ ] **Step 1: Add `quantity` to `ComponentModelPiece` type**
|
||||
|
||||
In `Inventory_frontend/app/shared/types/inventory.ts`, add `quantity` to the `ComponentModelPiece` interface (after `role`, line ~23):
|
||||
|
||||
```typescript
|
||||
quantity?: number
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `quantity` to `validatePiece()` in same file**
|
||||
|
||||
In `Inventory_frontend/app/shared/types/inventory.ts`, in `validatePiece()` (line ~144-172):
|
||||
|
||||
After `const role = ensureString(value.role)` (line ~161), add:
|
||||
|
||||
```typescript
|
||||
const quantity = typeof value.quantity === 'number' && value.quantity >= 1 ? value.quantity : undefined
|
||||
```
|
||||
|
||||
And in the return object, add after the `role` spread:
|
||||
|
||||
```typescript
|
||||
...(quantity ? { quantity } : {}),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `sanitizePieces()` to preserve quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/model/componentStructureSanitize.ts`, in `sanitizePieces()` (~line 130-188).
|
||||
|
||||
After the existing field extractions, add:
|
||||
|
||||
```typescript
|
||||
const quantity = typeof piece?.quantity === 'number' && piece.quantity >= 1 ? piece.quantity : undefined
|
||||
```
|
||||
|
||||
In the result object construction, add alongside existing fields (follow the `if (field) { result.field = field }` pattern used in this function):
|
||||
|
||||
```typescript
|
||||
if (quantity !== undefined) {
|
||||
result.quantity = quantity
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `normalizeStructureForSave()` to include quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/model/componentStructure.ts`, in `normalizeStructureForSave()` (~lines 164-179), add in the piece payload mapping after the `reference` check:
|
||||
|
||||
```typescript
|
||||
if ((piece as any).quantity !== undefined && (piece as any).quantity >= 1) {
|
||||
payload.quantity = (piece as any).quantity
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Always send quantity when defined (including 1), so the backend always has an explicit value.
|
||||
|
||||
- [ ] **Step 5: Update `hydratePieces()` and `mapComponentPieces()` to preserve quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/model/componentStructureHydrate.ts`:
|
||||
|
||||
In `hydratePieces()` (line ~95-107), add to the mapped object:
|
||||
|
||||
```typescript
|
||||
...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}),
|
||||
```
|
||||
|
||||
In `mapComponentPieces()` (line ~168-179), add to the mapped object:
|
||||
|
||||
```typescript
|
||||
...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}),
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Update `sanitizePieceDefinition()` to preserve quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/utils/structureAssignmentHelpers.ts`, in `sanitizePieceDefinition()` (~lines 172-180), add to the `stripNullish()` object:
|
||||
|
||||
```typescript
|
||||
quantity: typeof (definition as any).quantity === 'number' ? (definition as any).quantity : null,
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
git add app/shared/types/inventory.ts app/shared/model/componentStructure.ts app/shared/model/componentStructureSanitize.ts app/shared/model/componentStructureHydrate.ts app/shared/utils/structureAssignmentHelpers.ts
|
||||
git commit -m "feat(piece) : add quantity field to piece types, sanitization and hydration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Composant Structure Editor — Quantity Input
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/StructureNodeEditor.vue` (piece section, lines ~229-299)
|
||||
- Modify: `Inventory_frontend/app/composables/useStructureNodeCrud.ts` (`addPiece()`, lines ~110-118)
|
||||
|
||||
**Context:** `StructureNodeEditor.vue` renders the composant structure editor. The piece section (lines ~236-293) currently shows only a `select` for `typePieceId` and a delete button. The `addPiece()` function in `useStructureNodeCrud.ts` creates new piece entries with default fields.
|
||||
|
||||
- [ ] **Step 1: Add default quantity to `addPiece()`**
|
||||
|
||||
In `Inventory_frontend/app/composables/useStructureNodeCrud.ts`, in `addPiece()` (line ~110-118), add `quantity: 1` to the pushed object:
|
||||
|
||||
```typescript
|
||||
const addPiece = () => {
|
||||
ensureArray('pieces')
|
||||
props.node.pieces!.push({
|
||||
typePieceId: '',
|
||||
typePieceLabel: '',
|
||||
reference: '',
|
||||
familyCode: '',
|
||||
role: '',
|
||||
quantity: 1,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add quantity input in `StructureNodeEditor.vue`**
|
||||
|
||||
In `Inventory_frontend/app/components/StructureNodeEditor.vue`, in the piece item rendering section (inside the `v-for` loop for pieces, line ~256-292), add a quantity input next to the existing piece type `select`. Place it after the select and before the delete button:
|
||||
|
||||
```vue
|
||||
<input
|
||||
v-model.number="piece.quantity"
|
||||
type="number"
|
||||
:min="1"
|
||||
step="1"
|
||||
placeholder="Qté"
|
||||
class="input input-bordered input-sm md:input-md w-20"
|
||||
@input="piece.quantity = Math.max(1, piece.quantity || 1)"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
git add app/components/StructureNodeEditor.vue app/composables/useStructureNodeCrud.ts
|
||||
git commit -m "feat(piece) : add quantity input to composant structure editor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Machine Detail Page — Display Quantity
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/PieceItem.vue`
|
||||
|
||||
**Context:** `PieceItem.vue` renders each piece in the machine structure view. The piece name is displayed at line ~26 in an `<h3>` tag. Quantity should appear as "×N" after the name, in secondary text. For direct pieces (no parent component), it should be editable. For composant pieces, read-only.
|
||||
|
||||
- [ ] **Step 1: Add quantity display to PieceItem**
|
||||
|
||||
In `Inventory_frontend/app/components/PieceItem.vue`, after the piece name in the `<h3>` tag (line ~26), add the quantity display:
|
||||
|
||||
```vue
|
||||
<span
|
||||
v-if="displayQuantity > 1"
|
||||
class="text-sm font-normal text-base-content/60 ml-1"
|
||||
>
|
||||
×{{ displayQuantity }}
|
||||
</span>
|
||||
```
|
||||
|
||||
Add to the component's setup:
|
||||
|
||||
```typescript
|
||||
const displayQuantity = computed(() => {
|
||||
return props.piece.quantity ?? 1
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add editable quantity for direct machine pieces**
|
||||
|
||||
For pieces directly on a machine (no `parentComponentLinkId`), add an editable quantity input in the piece's edit section, following the pattern of existing override fields (nameOverride, referenceOverride, prixOverride). Place it alongside the overrides form:
|
||||
|
||||
```vue
|
||||
<div v-if="!piece.parentComponentLinkId && isEditMode" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">Quantité</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="pieceData.quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input input-bordered input-sm md:input-md w-24"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
Add `quantity` to the `pieceData` reactive object (line ~270-275):
|
||||
|
||||
```typescript
|
||||
quantity: props.piece.quantity ?? 1,
|
||||
```
|
||||
|
||||
Ensure this value is included in the data emitted when saving (follow the same pattern as `nameOverride`, `referenceOverride`, `prixOverride` in the save/emit logic).
|
||||
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
git add app/components/PieceItem.vue
|
||||
git commit -m "feat(piece) : display and edit quantity on machine piece items"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Submodule Update + Final Verification
|
||||
|
||||
**Files:**
|
||||
- Update submodule pointer in main repo
|
||||
|
||||
- [ ] **Step 1: Push frontend commits**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && git push
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update submodule pointer in main repo**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Inventory
|
||||
git add Inventory_frontend
|
||||
git commit -m "chore(frontend) : update submodule — piece quantity feature"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run all backend tests**
|
||||
|
||||
Run: `make test`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 4: Run migration on dev database**
|
||||
|
||||
```bash
|
||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Manual smoke test**
|
||||
|
||||
1. Open a composant edit page → verify quantity input appears on each piece in structure
|
||||
2. Set quantity to 4, save → reload → verify quantity persisted
|
||||
3. Open a machine with that composant → verify "×4" appears next to piece name (read-only)
|
||||
4. Add a piece directly to a machine → verify quantity input appears in edit mode
|
||||
5. Set quantity to 3, save → verify "×3" appears
|
||||
6. Clone the machine → verify cloned pieces have same quantities
|
||||
1582
docs/superpowers/plans/2026-03-13-modeltype-sync.md
Normal file
1582
docs/superpowers/plans/2026-03-13-modeltype-sync.md
Normal file
File diff suppressed because it is too large
Load Diff
1472
docs/superpowers/plans/2026-03-16-mcp-server.md
Normal file
1472
docs/superpowers/plans/2026-03-16-mcp-server.md
Normal file
File diff suppressed because it is too large
Load Diff
140
docs/superpowers/specs/2026-03-12-piece-quantity-design.md
Normal file
140
docs/superpowers/specs/2026-03-12-piece-quantity-design.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Piece Quantity — Design Spec
|
||||
|
||||
## Context
|
||||
|
||||
L'application gère des machines composées de composants et de pièces. Une même pièce (catalogue) peut apparaître dans plusieurs contextes avec des quantités différentes. La quantité doit être portée par la **relation**, pas par l'entité catalogue.
|
||||
|
||||
## Scope
|
||||
|
||||
- Quantité sur les **pièces directement liées à une machine** (`MachinePieceLink`)
|
||||
- Quantité sur les **pièces d'un composant** (JSON `structure.pieces` du `Composant`)
|
||||
- **Hors scope** : quantité sur `MachineComponentLink`, `MachineProductLink`, override de quantité composant au niveau machine, audit logging
|
||||
|
||||
## Règles métier
|
||||
|
||||
| Contexte | Stockage | Éditable depuis | Visible sur machine |
|
||||
|----------|----------|-----------------|---------------------|
|
||||
| Pièce directement sur machine | `MachinePieceLink.quantity` | Page machine | Oui, éditable |
|
||||
| Pièce d'un composant | `Composant.structure.pieces[].quantity` | Page composant (création + édition) | Oui, lecture seule |
|
||||
|
||||
- Type : entier, valeur par défaut = 1, minimum = 1
|
||||
- Affichage : "×N" après le nom de la pièce, masqué si N = 1
|
||||
- Quantité = 0 n'est pas valide (utiliser la suppression du lien à la place)
|
||||
|
||||
## Backend
|
||||
|
||||
### 1. MachinePieceLink — Nouvelle colonne
|
||||
|
||||
Ajout d'un champ `quantity` sur l'entité `MachinePieceLink` :
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
private int $quantity = 1;
|
||||
```
|
||||
|
||||
Getter/setter standard. **Pas de `#[Groups]`** — cohérent avec les autres champs de l'entité qui n'en déclarent pas (l'entité n'a pas de `normalizationContext`).
|
||||
|
||||
Validation : `#[Assert\GreaterThanOrEqual(1)]`
|
||||
|
||||
### 2. Migration SQL
|
||||
|
||||
```sql
|
||||
ALTER TABLE machine_piece_link ADD COLUMN IF NOT EXISTS quantity INTEGER NOT NULL DEFAULT 1;
|
||||
```
|
||||
|
||||
Idempotente avec `IF NOT EXISTS`.
|
||||
|
||||
### 3. Composant.structure JSON
|
||||
|
||||
Le tableau `pieces` dans le JSON `structure` du Composant accepte une nouvelle clé `quantity`. Pas de migration DB nécessaire — c'est un champ JSON libre.
|
||||
|
||||
Avant :
|
||||
```json
|
||||
{ "typePieceId": "...", "role": "Filtration" }
|
||||
```
|
||||
|
||||
Après :
|
||||
```json
|
||||
{ "typePieceId": "...", "role": "Filtration", "quantity": 4 }
|
||||
```
|
||||
|
||||
Les entrées existantes sans `quantity` sont traitées comme `quantity = 1` (défaut côté frontend et backend).
|
||||
|
||||
### 4. MachineStructureController
|
||||
|
||||
#### Normalisation (GET)
|
||||
|
||||
`normalizePieceLinks()` : inclure `quantity` dans la réponse JSON :
|
||||
- **Pièce machine directe** (parentLink = null) : `quantity` depuis `MachinePieceLink.quantity`
|
||||
- **Pièce sous composant** : `quantity` depuis le `structure.pieces` du composant source. Résolution :
|
||||
1. Naviguer `MachinePieceLink` → `parentLink` (MachineComponentLink) → `composant` → `structure['pieces']`
|
||||
2. Matcher par index de position dans le tableau `pieces` (l'ordre des pièces dans la structure correspond à l'ordre de création des liens)
|
||||
3. Fallback : `quantity = 1` si non trouvé
|
||||
|
||||
#### PATCH structure
|
||||
|
||||
Dans `applyPieceLinks()`, accepter `quantity` au même niveau que `pieceId` dans le payload :
|
||||
|
||||
```json
|
||||
{
|
||||
"pieceLinks": [
|
||||
{ "pieceId": "cl...", "quantity": 4, "overrides": { "nameOverride": "..." } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `quantity` est appliqué uniquement pour les pièces directement sur la machine (pas de `parentComponentLinkId`)
|
||||
- Si `parentComponentLinkId` est présent, `quantity` est **ignoré silencieusement** (la valeur vient du composant)
|
||||
|
||||
#### Clone
|
||||
|
||||
`clonePieceLinks()` doit copier `quantity` depuis le lien source :
|
||||
|
||||
```php
|
||||
$newLink->setQuantity($link->getQuantity());
|
||||
```
|
||||
|
||||
Sans cela, les machines clonées perdraient les quantités (reset à 1).
|
||||
|
||||
### 5. Tests
|
||||
|
||||
Ajouter dans `MachinePieceLinkTest.php` :
|
||||
- POST avec `quantity` explicite → vérifier la valeur
|
||||
- POST sans `quantity` → vérifier défaut = 1
|
||||
- PATCH `quantity` sur pièce directe → vérifier mise à jour
|
||||
- GET structure → vérifier `quantity` dans la réponse normalisée
|
||||
- Clone → vérifier que `quantity` est préservé
|
||||
|
||||
## Frontend
|
||||
|
||||
### 1. Types TypeScript
|
||||
|
||||
Mise à jour de `ComponentModelPiece` dans `shared/types/inventory.ts` — ajout du champ `quantity` :
|
||||
|
||||
```typescript
|
||||
quantity?: number // défaut 1
|
||||
```
|
||||
|
||||
### 2. Fonctions de sanitization/normalisation à mettre à jour
|
||||
|
||||
Ces fonctions énumèrent explicitement les champs à conserver et doivent inclure `quantity` :
|
||||
|
||||
- `normalizeStructureForSave()` dans `shared/model/componentStructure.ts` — inclure `quantity` dans le payload backend des pièces
|
||||
- `sanitizePieceDefinition()` dans `shared/utils/structureAssignmentHelpers.ts` — préserver `quantity`
|
||||
- `sanitizePieces()` dans `shared/model/componentStructureSanitize.ts` — préserver `quantity` dans la sortie
|
||||
- `hydratePieces()` / `mapComponentPieces()` — préserver `quantity` lors de l'hydratation
|
||||
|
||||
### 3. Pages composant (création + édition)
|
||||
|
||||
Dans l'éditeur de structure, chaque pièce du tableau `structure.pieces` affiche un champ input :
|
||||
- Type : `number`, min = 1, step = 1
|
||||
- Valeur par défaut : 1
|
||||
- Style : `input input-bordered input-sm md:input-md` (DaisyUI)
|
||||
- Position : à côté des champs existants (reference, role)
|
||||
|
||||
### 4. Page machine (détail/structure)
|
||||
|
||||
- **Pièce directe** (parentLink = null) : affiche "×N" à côté du nom, quantité éditable (input entier)
|
||||
- **Pièce de composant** : affiche "×N" à côté du nom, lecture seule (pas d'input)
|
||||
- Si quantité = 1 : rien n'est affiché (pas de bruit visuel)
|
||||
- Style du label : texte secondaire (`text-base-content/60` ou classe équivalente)
|
||||
502
docs/superpowers/specs/2026-03-13-modeltype-sync-design.md
Normal file
502
docs/superpowers/specs/2026-03-13-modeltype-sync-design.md
Normal file
@@ -0,0 +1,502 @@
|
||||
# ModelType Sync — Design Spec
|
||||
|
||||
## Objectif
|
||||
|
||||
Quand un ModelType (catégorie) est modifié (structure, custom fields), propager automatiquement les changements à tous les items liés (Composants, Pièces, Produits). L'utilisateur voit un preview de l'impact et confirme avant que la sync ne s'exécute.
|
||||
|
||||
## Décisions
|
||||
|
||||
| Décision | Choix |
|
||||
|----------|-------|
|
||||
| Scope sync | Composants + Pièces + Produits |
|
||||
| Sync destructive | Avec confirmation (modal frontend) |
|
||||
| Custom fields — ajout | Créer `CustomFieldValue` vides |
|
||||
| Custom fields — suppression | Supprimer avec confirmation |
|
||||
| Custom fields — renommage | Propagation auto (label dans la définition) |
|
||||
| Custom fields — changement de type | Clear les valeurs avec confirmation |
|
||||
| Architecture backend | Strategy pattern (1 strategy par entity type) |
|
||||
| Déclenchement | En deux temps : preview séparé du sync |
|
||||
| Preview timing | AVANT le save (pas de rollback nécessaire) |
|
||||
| Pièces — produits liés | Nouvelle table `PieceProductSlot` remplace la M2M `piece_products` |
|
||||
| `restrictedMode` frontend | Supprimé complètement |
|
||||
| Versioning | `version` INT sur Composant, Pièce, Produit (incrémenté à chaque sync) |
|
||||
| Machines | Aucun changement — elles lisent les slots des composants, la sync met à jour ces slots |
|
||||
| Matching slots | Par `typeXxxId` + `position` (pas de FK vers skeleton requirement) |
|
||||
| Matching custom fields | Par `orderIndex` (propriété stable sur `CustomField`) |
|
||||
| Atomicité PATCH + sync | Wrappé dans une transaction DB côté controller |
|
||||
| Idempotence sync | `execute()` est idempotent — un double appel est un no-op |
|
||||
| Audit | Les opérations de sync sont capturées par les subscribers `onFlush` existants |
|
||||
|
||||
## Endpoints API
|
||||
|
||||
### `POST /api/model_types/{id}/sync-preview`
|
||||
|
||||
Calcule l'impact du diff entre le payload envoyé et l'état actuel des items liés. **Ne persiste rien.**
|
||||
|
||||
**Sécurité :** `ROLE_GESTIONNAIRE`
|
||||
|
||||
**Request body :**
|
||||
```json
|
||||
{
|
||||
"structure": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
Le payload `structure` a le même format que celui envoyé au `PATCH /api/model_types/{id}`.
|
||||
|
||||
**Response :**
|
||||
```json
|
||||
{
|
||||
"modelTypeId": "cl...",
|
||||
"category": "COMPONENT",
|
||||
"itemCount": 12,
|
||||
"additions": {
|
||||
"pieceSlots": 12,
|
||||
"productSlots": 0,
|
||||
"subcomponentSlots": 24,
|
||||
"customFieldValues": 36
|
||||
},
|
||||
"deletions": {
|
||||
"pieceSlots": 0,
|
||||
"productSlots": 12,
|
||||
"subcomponentSlots": 0,
|
||||
"customFieldValues": 0
|
||||
},
|
||||
"modifications": {
|
||||
"customFieldTypeChanges": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Si `additions`, `deletions` et `modifications` sont tous à 0, le frontend skip la modal et sauvegarde directement.
|
||||
|
||||
**Erreurs :**
|
||||
- `404` — ModelType introuvable
|
||||
- `403` — droits insuffisants
|
||||
|
||||
### `POST /api/model_types/{id}/sync`
|
||||
|
||||
Exécute la propagation. Appelé **après** le `PATCH` du ModelType, dans la même requête frontend (PATCH + sync enchaînés).
|
||||
|
||||
**Sécurité :** `ROLE_GESTIONNAIRE`
|
||||
|
||||
**Request body :**
|
||||
```json
|
||||
{
|
||||
"confirmDeletions": true,
|
||||
"confirmTypeChanges": true
|
||||
}
|
||||
```
|
||||
|
||||
Si des suppressions sont nécessaires mais `confirmDeletions` est `false`, le sync **skip les suppressions** (applique uniquement les ajouts). Idem pour `confirmTypeChanges` et les clear de valeurs. Cela permet un sync partiel (ajouts only) sans confirmation.
|
||||
|
||||
**Response :** `200` avec résumé de l'exécution.
|
||||
|
||||
**Erreurs :**
|
||||
- `404` — ModelType introuvable
|
||||
- `403` — droits insuffisants
|
||||
|
||||
## Architecture Backend
|
||||
|
||||
### Strategy Pattern
|
||||
|
||||
```
|
||||
Service/
|
||||
├── ModelTypeSyncService.php # Orchestrateur
|
||||
└── Sync/
|
||||
├── SyncStrategyInterface.php # Interface
|
||||
├── ComposantSyncStrategy.php # Slots pièce/produit/sous-composant + custom fields
|
||||
├── PieceSyncStrategy.php # Slots produit + custom fields
|
||||
└── ProductSyncStrategy.php # Custom fields uniquement
|
||||
```
|
||||
|
||||
### Interface
|
||||
|
||||
```php
|
||||
interface SyncStrategyInterface
|
||||
{
|
||||
public function supports(ModelType $modelType): bool;
|
||||
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult;
|
||||
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult;
|
||||
}
|
||||
```
|
||||
|
||||
**Note sur `execute()` :** Cette méthode est appelée **après** le PATCH du ModelType, donc les skeleton requirements sont déjà mis à jour en base. La strategy compare les skeleton requirements actuels (fraîchement mis à jour) avec les slots existants des items liés. Pas besoin de recevoir `$newStructure` — les relations ORM reflètent déjà le nouvel état.
|
||||
|
||||
### Orchestrateur
|
||||
|
||||
```php
|
||||
class ModelTypeSyncService
|
||||
{
|
||||
/** @param iterable<SyncStrategyInterface> $strategies */
|
||||
public function __construct(private iterable $strategies) {}
|
||||
|
||||
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult
|
||||
{
|
||||
foreach ($this->strategies as $strategy) {
|
||||
if ($strategy->supports($modelType)) {
|
||||
return $strategy->preview($modelType, $newStructure);
|
||||
}
|
||||
}
|
||||
throw new \LogicException('No strategy found for category: ' . $modelType->getCategory()->value);
|
||||
}
|
||||
|
||||
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult
|
||||
{
|
||||
foreach ($this->strategies as $strategy) {
|
||||
if ($strategy->supports($modelType)) {
|
||||
return $strategy->execute($modelType, $confirmation);
|
||||
}
|
||||
}
|
||||
throw new \LogicException('No strategy found for category: ' . $modelType->getCategory()->value);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Les strategies sont auto-injectées via `#[AutoconfigureTag('app.sync_strategy')]` et le tagged iterator de Symfony.
|
||||
|
||||
### DTOs
|
||||
|
||||
```php
|
||||
class SyncPreviewResult
|
||||
{
|
||||
public string $modelTypeId;
|
||||
public string $category;
|
||||
public int $itemCount;
|
||||
public array $additions; // ['pieceSlots' => int, 'productSlots' => int, ...]
|
||||
public array $deletions;
|
||||
public array $modifications;
|
||||
}
|
||||
|
||||
class SyncConfirmation
|
||||
{
|
||||
public bool $confirmDeletions = false;
|
||||
public bool $confirmTypeChanges = false;
|
||||
}
|
||||
|
||||
class SyncExecutionResult
|
||||
{
|
||||
public int $itemsUpdated;
|
||||
public array $additions;
|
||||
public array $deletions;
|
||||
public array $modifications;
|
||||
}
|
||||
```
|
||||
|
||||
### Controller
|
||||
|
||||
```php
|
||||
#[Route('/api/model_types/{id}')]
|
||||
class ModelTypeSyncController extends AbstractController
|
||||
{
|
||||
#[Route('/sync-preview', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_GESTIONNAIRE')]
|
||||
public function preview(ModelType $modelType, Request $request): JsonResponse
|
||||
{
|
||||
$structure = json_decode($request->getContent(), true)['structure'] ?? [];
|
||||
$result = $this->syncService->preview($modelType, $structure);
|
||||
return $this->json($result);
|
||||
}
|
||||
|
||||
#[Route('/sync', methods: ['POST'])]
|
||||
#[IsGranted('ROLE_GESTIONNAIRE')]
|
||||
public function sync(ModelType $modelType, Request $request): JsonResponse
|
||||
{
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$confirmation = new SyncConfirmation();
|
||||
$confirmation->confirmDeletions = $body['confirmDeletions'] ?? false;
|
||||
$confirmation->confirmTypeChanges = $body['confirmTypeChanges'] ?? false;
|
||||
$result = $this->syncService->execute($modelType, $confirmation);
|
||||
return $this->json($result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Atomicité PATCH + Sync
|
||||
|
||||
Le frontend enchaîne `PATCH` puis `POST /sync` en deux requêtes HTTP. Le `POST /sync` wrappe toute l'opération dans une transaction DB (`$em->wrapInTransaction()`). Si le sync échoue, les modifications de slots sont rollback. Le PATCH du ModelType (skeleton requirements) reste committée — c'est acceptable car un re-sync est toujours possible.
|
||||
|
||||
En cas d'échec réseau entre le PATCH et le sync, le ModelType est à jour mais les items ne sont pas synchronisés. Le prochain save de la catégorie reproposera le sync-preview, qui détectera les différences.
|
||||
|
||||
## Logique de Diff / Sync
|
||||
|
||||
### Matching des slots
|
||||
|
||||
Pour chaque item lié, on compare ses slots actuels avec les skeleton requirements du ModelType. Le matching se fait par **`typeXxxId`** (le type référencé : `typePieceId`, `typeProductId`, `typeComposantId`) + **`position`**.
|
||||
|
||||
Il n'y a **pas de FK directe** entre un slot et un skeleton requirement. Le lien est implicite via le type + position.
|
||||
|
||||
**Pour le preview :** la strategy parse le `$newStructure` (payload JSON) et le compare aux slots actuels sans toucher à la DB.
|
||||
|
||||
**Pour l'execute :** la strategy lit les skeleton requirements actuels (déjà mis à jour par le PATCH) et les compare aux slots actuels.
|
||||
|
||||
### Règles — Slots (pièce, produit, sous-composant)
|
||||
|
||||
| Cas | Action |
|
||||
|-----|--------|
|
||||
| Skeleton requirement existe, pas de slot correspondant | **Ajouter** slot vide (type + position, `quantity = 1` pour pièces, pas de sélection) |
|
||||
| Slot existe, plus de skeleton requirement | **Supprimer** le slot (si `confirmDeletions`) — sélection perdue |
|
||||
| Les deux existent, position différente | **Mettre à jour** la position du slot |
|
||||
| Slot existe et matche | **Ne rien toucher** — sélection et quantité préservées |
|
||||
|
||||
### Règles — Custom fields
|
||||
|
||||
| Cas | Action |
|
||||
|-----|--------|
|
||||
| Nouveau custom field | **Créer** `CustomFieldValue` vides pour tous les items |
|
||||
| Custom field supprimé | **Supprimer** les `CustomFieldValue` (si `confirmDeletions`) |
|
||||
| Renommé (même `orderIndex`, nom différent) | **Propagation auto** — label dans la définition, valeurs intactes |
|
||||
| Type changé (même `orderIndex`, type différent) | **Clear** les valeurs (si `confirmTypeChanges`) — `CustomFieldValue` conservée, `value` vidée |
|
||||
|
||||
Le matching des custom fields se fait par **`orderIndex`** (propriété stable sur l'entité `CustomField`), pas par index de tableau. Cela évite les faux positifs lors de réordonnancement.
|
||||
|
||||
## Nouvelle Entité — `PieceProductSlot`
|
||||
|
||||
### Contexte — Remplacement de la M2M `piece_products`
|
||||
|
||||
Actuellement, les produits liés aux pièces passent par une relation M2M (`piece_products`, colonnes `a`/`b`). Cette table n'a pas de notion de `position`, `typeProductId`, ou `familyCode`.
|
||||
|
||||
`PieceProductSlot` **remplace** cette M2M pour uniformiser l'architecture avec les slots des Composants. La M2M existante sera conservée temporairement puis supprimée dans une migration future.
|
||||
|
||||
### Table `piece_product_slots`
|
||||
|
||||
| Colonne | Type | Contrainte |
|
||||
|---------|------|------------|
|
||||
| `id` | VARCHAR (CUID) | PK |
|
||||
| `pieceid` | VARCHAR | FK → `pieces.id` CASCADE |
|
||||
| `typeproductid` | VARCHAR | FK → `model_types.id` SET NULL, nullable |
|
||||
| `selectedproductid` | VARCHAR | FK → `products.id` SET NULL, nullable |
|
||||
| `familycode` | VARCHAR(255) | nullable |
|
||||
| `position` | INT | NOT NULL |
|
||||
| `createdat` | TIMESTAMP | NOT NULL |
|
||||
| `updatedat` | TIMESTAMP | NOT NULL |
|
||||
|
||||
### Entité PHP
|
||||
|
||||
```php
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'piece_product_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class PieceProductSlot
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: 'string')]
|
||||
private string $id;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'productSlots')]
|
||||
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Piece $piece;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeProduct = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Product::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Product $selectedProduct = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE)]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
}
|
||||
```
|
||||
|
||||
### Relation sur Piece
|
||||
|
||||
```php
|
||||
#[ORM\OneToMany(targetEntity: PieceProductSlot::class, mappedBy: 'piece', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $productSlots;
|
||||
```
|
||||
|
||||
La relation M2M `$products` existante sur `Piece` sera marquée deprecated puis supprimée dans une migration future.
|
||||
|
||||
### Migration
|
||||
|
||||
1. Créer la table `piece_product_slots`
|
||||
2. Migrer les données existantes de `piece_products` (M2M) → chaque entrée devient un `PieceProductSlot` avec `selectedProductId` renseigné, `typeProductId` déduit du produit sélectionné (`product.typeProduct`), `position` auto-incrémentée
|
||||
3. Conserver `piece_products` temporairement (suppression dans une migration future)
|
||||
4. Mettre à jour le virtual getter `getStructure()` de Piece pour lire les `productSlots`
|
||||
|
||||
## Versioning
|
||||
|
||||
### Nouveau champ sur Composant, Piece, Product
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: 'integer', options: ['default' => 1])]
|
||||
#[Groups(['composant:read'])] // idem pour piece:read, product:read
|
||||
private int $version = 1;
|
||||
```
|
||||
|
||||
### Comportement
|
||||
|
||||
- **Création** d'un item → `version = 1`
|
||||
- **Sync** qui modifie les slots ou custom fields d'un item → `version += 1`
|
||||
- Si la sync n'a aucun impact sur un item particulier (ses slots matchent déjà le skeleton), sa version ne change pas
|
||||
|
||||
### Migration
|
||||
|
||||
```sql
|
||||
ALTER TABLE composants ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1;
|
||||
ALTER TABLE pieces ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1;
|
||||
ALTER TABLE products ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1;
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
### Suppression du `restrictedMode`
|
||||
|
||||
**Fichiers à supprimer :**
|
||||
- `composables/useCategoryEditGuard.ts`
|
||||
- `tests/composables/useCategoryEditGuard.test.ts`
|
||||
|
||||
**Fichiers à modifier (retirer restrictedMode) :**
|
||||
- `pages/component-category/[id]/edit.vue`
|
||||
- `pages/piece-category/[id]/edit.vue`
|
||||
- `pages/product-category/[id]/edit.vue`
|
||||
- `components/StructureNodeEditor.vue`
|
||||
- `components/PieceModelStructureEditor.vue`
|
||||
- `components/ComponentModelStructureEditor.vue`
|
||||
- `components/model-types/ModelTypeForm.vue`
|
||||
- `composables/useStructureNodeCrud.ts`
|
||||
- `composables/useStructureNodeLogic.ts`
|
||||
- `composables/usePieceStructureEditorLogic.ts`
|
||||
- `tests/components/ModelTypeForm.test.ts` (si existant)
|
||||
- `tests/components/PieceModelStructureEditor.test.ts`
|
||||
|
||||
### Nouveau composant — `SyncConfirmationModal.vue`
|
||||
|
||||
Modal DaisyUI qui reçoit un `SyncPreviewResult` et affiche :
|
||||
|
||||
```
|
||||
Cette modification impacte X [composants|pièces|produits] :
|
||||
|
||||
Ajouts :
|
||||
• Y slots pièce à créer
|
||||
• Z valeurs de champs personnalisés à initialiser
|
||||
|
||||
Suppressions :
|
||||
• W slots produit à supprimer (les sélections seront perdues)
|
||||
|
||||
Modifications :
|
||||
• V valeurs de champs à réinitialiser (changement de type)
|
||||
|
||||
[Annuler] [Confirmer la synchronisation]
|
||||
```
|
||||
|
||||
### Nouveau service — `modelTypes.ts`
|
||||
|
||||
Ajout de deux fonctions au service existant :
|
||||
|
||||
```typescript
|
||||
export function syncPreview(id: string, structure: any) {
|
||||
return requestFetch(`/api/model_types/${id}/sync-preview`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ structure }),
|
||||
})
|
||||
}
|
||||
|
||||
export function syncExecute(id: string, confirmation: { confirmDeletions: boolean, confirmTypeChanges: boolean }) {
|
||||
return requestFetch(`/api/model_types/${id}/sync`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(confirmation),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Flow dans les pages d'édition de catégorie
|
||||
|
||||
```typescript
|
||||
const handleSubmit = async (payload) => {
|
||||
// 1. Preview (avant le save)
|
||||
const preview = await syncPreview(id, payload.structure)
|
||||
|
||||
const hasImpact = preview.itemCount > 0 && (
|
||||
Object.values(preview.additions).some(v => v > 0) ||
|
||||
Object.values(preview.deletions).some(v => v > 0) ||
|
||||
Object.values(preview.modifications).some(v => v > 0)
|
||||
)
|
||||
|
||||
// 2. Si impact, demander confirmation
|
||||
if (hasImpact) {
|
||||
pendingPayload.value = payload
|
||||
syncPreviewData.value = preview
|
||||
showSyncModal.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Pas d'impact → save direct (PATCH + sync)
|
||||
await saveAndSync(payload, { confirmDeletions: false, confirmTypeChanges: false })
|
||||
}
|
||||
|
||||
const onSyncConfirmed = async () => {
|
||||
const preview = syncPreviewData.value
|
||||
const needsDeleteConfirm = Object.values(preview.deletions).some(v => v > 0)
|
||||
const needsTypeChangeConfirm = preview.modifications.customFieldTypeChanges > 0
|
||||
|
||||
await saveAndSync(pendingPayload.value, {
|
||||
confirmDeletions: needsDeleteConfirm,
|
||||
confirmTypeChanges: needsTypeChangeConfirm,
|
||||
})
|
||||
}
|
||||
|
||||
const saveAndSync = async (payload, confirmation) => {
|
||||
await updateModelType(id, payload)
|
||||
await syncExecute(id, confirmation)
|
||||
showSuccess('Catégorie mise à jour et synchronisée.')
|
||||
router.push('/...')
|
||||
}
|
||||
```
|
||||
|
||||
## Non-régression
|
||||
|
||||
### Machines
|
||||
|
||||
- Le `MachineStructureController` lit les slots des composants. La sync modifie ces slots → les machines affichent automatiquement la dernière version au prochain chargement.
|
||||
- Aucun changement dans le controller machine.
|
||||
|
||||
### Quantités
|
||||
|
||||
- La sync **ne touche jamais** aux slots qui matchent toujours un skeleton requirement. Les quantités (`ComposantPieceSlot.quantity`) et sélections existantes sont préservées.
|
||||
- Les nouveaux slots ajoutés par la sync ont `quantity = 1` par défaut.
|
||||
- Le `ComposantPieceSlotController` (PATCH quantity) reste inchangé.
|
||||
|
||||
### `PieceProductSlot` — pas de quantité
|
||||
|
||||
Cohérent avec `ComposantProductSlot` qui n'a pas de quantité non plus.
|
||||
|
||||
### Relation M2M `piece_products`
|
||||
|
||||
La M2M existante reste en base pendant la période de transition. Le code frontend/backend qui la lit devra être migré vers les `productSlots`. La M2M sera supprimée dans une migration future une fois que tout le code utilise les slots.
|
||||
|
||||
## Tests
|
||||
|
||||
### Backend — PHPUnit
|
||||
|
||||
- `ModelTypeSyncControllerTest` — tests des endpoints preview et sync (y compris erreurs 403/404, confirmations partielles)
|
||||
- `ComposantSyncStrategyTest` — logique de diff pour composants (ajout, suppression, position update, no-op)
|
||||
- `PieceSyncStrategyTest` — logique de diff pour pièces (ajout/suppression de product slots)
|
||||
- `ProductSyncStrategyTest` — logique de diff pour produits (custom fields only)
|
||||
- `PieceProductSlotTest` — CRUD de la nouvelle entité
|
||||
- Idempotence : vérifier qu'un double appel à `sync` est un no-op
|
||||
- Vérifier la non-régression : `MachineStructureControllerTest` existant doit passer sans modification
|
||||
|
||||
### Frontend — Tests
|
||||
|
||||
- Supprimer `tests/composables/useCategoryEditGuard.test.ts`
|
||||
- Mettre à jour `tests/components/PieceModelStructureEditor.test.ts` (retirer restrictedMode)
|
||||
- Ajouter tests pour le flow sync dans les pages d'édition (preview → modal → confirm → save)
|
||||
|
||||
## Performance
|
||||
|
||||
Pour le volume attendu (dizaines d'items par catégorie, pas de milliers), la sync en PHP avec l'ORM Doctrine est suffisante. Si le volume augmente significativement, les opérations de création/suppression de slots pourront être converties en batch SQL (INSERT ... SELECT, DELETE ... WHERE) sans changer l'architecture (la strategy encapsule la logique).
|
||||
669
docs/superpowers/specs/2026-03-16-mcp-server-design.md
Normal file
669
docs/superpowers/specs/2026-03-16-mcp-server-design.md
Normal 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.
|
||||
@@ -105,6 +105,114 @@ INSERT INTO public.model_types (id, name, code, category, notes, createdat, upda
|
||||
INSERT INTO public.model_types (id, name, code, category, notes, createdat, updatedat, description, componentskeleton, pieceskeleton, productskeleton) VALUES ('cle48e33ef67853069badfc5f0', 'testcor', 'testcor', 'COMPONENT', 'nnd', '2026-01-25 11:27:47', '2026-01-25 11:27:47', 'nnd', '{"pieces": [{"typePieceId": "cmgs1sco0002k47056yq8eyfq", "typePieceLabel": "Bavette"}], "products": [{"familyCode": "lubrifiant", "typeProductId": "cmhn9zrm5000247s8ds3bmpaf"}], "customFields": [{"key": "uu", "value": {"type": "text", "required": false}}], "subcomponents": [{"alias": "Trémie d''alimentation", "familyCode": "Tremie", "typeComposantId": "cmgs0htn5002d4705tyxxiqb2"}]}', NULL, NULL);
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: skeleton_piece_requirements; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0939a6d5cf1e2f42ea338ed9', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrnu6zc000f470565mc8hha', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clc8a5a3ffde2e012115f4a4a6', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrnxlx5000g47059oyj4yuw', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl19977b37375e768874a9bc21', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrohigo000z4705q8yvpih0', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('claa3bd610232237f37b5305f5', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrou6670011470586ipgylm', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl8cc6f7b1c0d2356200710cff', 'cmgrp0u0h00124705xxyt5fqu', 'cmgroij2f00104705t6y33enk', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl6ab668cf6c44971a6e8c15c1', 'cmgrp0u0h00124705xxyt5fqu', 'cmgrnu6zc000f470565mc8hha', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl9d6c1d30a8f1f37d6f5919ca', 'cmgrzrlcc001t47054emo6cfb', 'cmgrzuwkj001u47057u8hej9u', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl89cc18036bb3a12d4d0cd427', 'cmgrzrlcc001t47054emo6cfb', 'cmgrzuwkj001u47057u8hej9u', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl6f6e8f47c6a731bcd9288bba', 'cmgrzrlcc001t47054emo6cfb', 'cmgrzuwkj001u47057u8hej9u', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clc7fe6f18be36b9cd60089a28', 'cmgrzrlcc001t47054emo6cfb', 'cmgroij2f00104705t6y33enk', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl15ff333888d9f160a80a04e5', 'cmgrzrlcc001t47054emo6cfb', 'cmgroij2f00104705t6y33enk', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl82c521ac9e0cae5ba31d2880', 'cmgs0h5ze002c4705szh85svi', 'cmgroij2f00104705t6y33enk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl3eef5c0837bdf0aed9da51b3', 'cmgs0h5ze002c4705szh85svi', 'cmgs0kd5o002f47053b7n8tw6', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl2533d3d3f215df0c180b2c4a', 'cmgs0h5ze002c4705szh85svi', 'cmgrzuwkj001u47057u8hej9u', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl3ff44ebbbd85d8750328183c', 'cmgs0h5ze002c4705szh85svi', 'cmgroij2f00104705t6y33enk', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clbc503ec073758ef2f4cd2a4f', 'cmgs0htn5002d4705tyxxiqb2', 'cmgs13jjp002h4705rjqzz5lh', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl80631b5e0a1ab10d4af65f6f', 'cmgs0htn5002d4705tyxxiqb2', 'cmgs1s4pv002j470567o60oqe', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clc11a5ad82b02a76eca599117', 'cmgs0htn5002d4705tyxxiqb2', 'cmgs1sco0002k47056yq8eyfq', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clbd335edf794794fba25546a6', 'cmgs0htn5002d4705tyxxiqb2', 'cmgs1sco0002k47056yq8eyfq', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl218404db04ffcd7c8e200f0a', 'cmgs0i4je002e4705ndrhe26e', 'cmgroij2f00104705t6y33enk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl31265ec0dcf2fc4e252c2181', 'cmgs0i4je002e4705ndrhe26e', 'cmgrzuwkj001u47057u8hej9u', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl107b717d92362218aa387539', 'cmgs0i4je002e4705ndrhe26e', 'cmgujpyjf002q4705j6hv1nkk', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5ee1bef2ef4b88f83a680526', 'cmgs0i4je002e4705ndrhe26e', 'cmgs1s4pv002j470567o60oqe', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl49eb01162df881cc87bc281f', 'cmgs0i4je002e4705ndrhe26e', 'cmgrnxlx5000g47059oyj4yuw', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl1369592934d41016018dbbbf', 'cmgs0i4je002e4705ndrhe26e', 'cmgujpyjf002q4705j6hv1nkk', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cld7bf0d8c46d0037cbb16cd69', 'cmgs0i4je002e4705ndrhe26e', 'cmgs1s4pv002j470567o60oqe', 6, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl75660a7f65559f164df06538', 'cmgs0i4je002e4705ndrhe26e', 'cm_motoreducteur_frein_01', 7, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl859ade5b78b6e833d1eb64f8', 'cmgs0i4je002e4705ndrhe26e', 'cm_motoreducteur_frein_01', 8, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cld23bbbeff05e238c6d189da0', 'cmgs0i4je002e4705ndrhe26e', 'cmgukvztv002s4705kqvqjtvg', 9, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5e7a3eeeb8a6283ce828edf4', 'cmgs0i4je002e4705ndrhe26e', 'cmgukvztv002s4705kqvqjtvg', 10, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl52149961cdaed3f614dd87c1', 'cmgs0i4je002e4705ndrhe26e', 'cmgukxw26002t4705qz4ul929', 11, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cle1d646b02f65c7a5fd276ecf', 'cmgs0i4je002e4705ndrhe26e', 'cmgum1ih0000347ff7bsldmnv', 12, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl07b21cfdc0f2e9d8c11c79ed', 'cmgs0i4je002e4705ndrhe26e', 'cmgulzr7b000247ffpr2vsput', 13, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clb726dc1c94d371d8860ef92d', 'cmgs0i4je002e4705ndrhe26e', 'cmgum1wsl000447ffa109dtag', 14, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cle5605528c6fbb3de1f72b8d8', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clbd602414e77f0309cfeb92c6', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl604a3fa207118914f58f5ffb', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl016fd78557d8ac5ce883208b', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl29eeddd05188eec4e782aca3', 'cmgujizjf002o4705kfdea5yw', 'cmgytewe0002447ffup09bscr', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl7e7a30ba2126ecfed7f7501c', 'cmgujizjf002o4705kfdea5yw', 'cmgz0qu29004a47ffw1bmjr75', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clb893d014b683a1851425436e', 'cmgujizjf002o4705kfdea5yw', 'cmgz0qu29004a47ffw1bmjr75', 6, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0b0094ca0a92321f5fa2131c', 'cmgujizjf002o4705kfdea5yw', 'cmgz0v9k4004v47ff8apimo50', 7, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clea857995e00ebb10548095c2', 'cmgujizjf002o4705kfdea5yw', 'cmgz0v9k4004v47ff8apimo50', 8, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl1341b6e156f059e852f719e2', 'cmgujizjf002o4705kfdea5yw', 'cmgz0v9k4004v47ff8apimo50', 9, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cle18462b08b605f31fec6cc14', 'cmgujizjf002o4705kfdea5yw', 'cmgz0v9k4004v47ff8apimo50', 10, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clf3f7ad1fcb6eb3937b96bb8e', 'cmgujizjf002o4705kfdea5yw', 'cmgz0zs4m006447ffq5b20ch3', 11, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clfdd9dfcc06816afd8bf8503a', 'cmgujizjf002o4705kfdea5yw', 'cmgz0zs4m006447ffq5b20ch3', 12, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl4af9588815f4a5a21cf4f979', 'cmgujizjf002o4705kfdea5yw', 'cmgz0zs4m006447ffq5b20ch3', 13, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0e0d7053e7a0518b50f00481', 'cmgujizjf002o4705kfdea5yw', 'cmgz0zs4m006447ffq5b20ch3', 14, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl4f64c44ed0ffc92a923edccc', 'cmgujizjf002o4705kfdea5yw', 'cmgz17bpz006t47ff58i3j1e1', 15, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clfbdc4656e36b47a8e6a5df68', 'cmgujizjf002o4705kfdea5yw', 'cmgz17bpz006t47ff58i3j1e1', 16, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl4860204e0478d9f4eb806b85', 'cmgujjmpo002p470523lbfqmp', 'cmgum1wsl000447ffa109dtag', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5e3367975bfef877130e41af', 'cmgujjmpo002p470523lbfqmp', 'cmgum1wsl000447ffa109dtag', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0c004dd01a3f4a978c8260c9', 'cmh0kmyh1000847s5ciu9agzo', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl96109b5962d80d95d279fd96', 'cmh0kmyh1000847s5ciu9agzo', 'cmgytewe0002447ffup09bscr', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5a6e124fa8ec83ae5ef21685', 'cmh20wuye001o47s5auvmq7s8', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clba2c60a3a4aab6a82eccad61', 'cmh20x49u001q47s5l9ahnvms', 'cmgs1sco0002k47056yq8eyfq', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clbf346fb41ad28af45eb5e7aa', 'cmh20x49u001q47s5l9ahnvms', 'cmgs1sco0002k47056yq8eyfq', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl50ec69f08be67181d3cd5dc3', 'cmh20yrgb001v47s54uxvi6km', 'cmgs1sco0002k47056yq8eyfq', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl84cab14314d4932ef52e9d47', 'cmh20yrgb001v47s54uxvi6km', 'cmgujpyjf002q4705j6hv1nkk', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cle258e4bb4ae12d90aeb49ae7', 'cmh20yrgb001v47s54uxvi6km', 'cmgujpyjf002q4705j6hv1nkk', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl697c36fe5e29f3cfdb209537', 'cmh20yrgb001v47s54uxvi6km', 'cmgujpyjf002q4705j6hv1nkk', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl112cc747d5ac2c68716bd6d4', 'cmh23pwbo002947s5ide6zx7g', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl62fe319d24840a318d24b682', 'cmkqpdc7a001o1eq6iwqvi3jk', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0051b0a075490c406f21213e', 'cmkqq45yq00251eq6k1z0x7kt', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clc578afcad0f42694f05408f0', 'cmkqqdogo002l1eq6vy26j33g', 'cmgujpyjf002q4705j6hv1nkk', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl0a3a926192f808233c894c43', 'cmkqqdogo002l1eq6vy26j33g', 'cmh9bykt8001j47v7g0oej5dw', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl8a927a5de52db535ba8f062a', 'cmkqqdogo002l1eq6vy26j33g', 'cmhabzypq003h47v7jyjjxst1', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl8f60c43f25ec1c96560209b0', 'cmkqqdogo002l1eq6vy26j33g', 'cmgz0zs4m006447ffq5b20ch3', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('cl5c71200fd7efe31c9764e5d8', 'cmkqqdogo002l1eq6vy26j33g', 'cmkdqtcpv001r1e2wptehmkxi', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_piece_requirements (id, modeltypeid, typepieceid, position, createdat, updatedat) VALUES ('clf6147721769314755e9ff6b8', 'cmkqqdogo002l1eq6vy26j33g', 'cmhbve5h30016475utwgpa32k', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: skeleton_product_requirements; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.skeleton_product_requirements (id, modeltypeid, typeproductid, familycode, position, createdat, updatedat) VALUES ('cle44c43c22390db28f97b1c17', 'cmkqqdogo002l1eq6vy26j33g', 'cmhn9ze17000147s81dlr4i3v', 'graisse', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: skeleton_subcomponent_requirements; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl9806dc8b4b42ea0c84b6832b', 'cmgs0i4je002e4705ndrhe26e', NULL, 'Kit', 'kit', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl67fc8aef811eb876a1cde9ed', 'cmgs0i4je002e4705ndrhe26e', NULL, 'Kit', 'kit', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl6cff73d32c4f16b5d4e909ab', 'cmgujjmpo002p470523lbfqmp', NULL, 'Kit', 'kit', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl7f83953a8f78f951f3a3628c', 'cmgz1az8d007g47fflwxk3q95', NULL, 'Contrôleur de rotation', 'controleur-de-rotation', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cle79fbc1bedd4123874a35377', 'cmgz1az8d007g47fflwxk3q95', NULL, 'Kit', 'kit', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl23c07d5454e5f2bc6d7703fa', 'cmgz1az8d007g47fflwxk3q95', NULL, 'Kit', 'kit', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cla3530a303f28a12d97883d82', 'cmgz1az8d007g47fflwxk3q95', NULL, 'Contrôleur de rotation', 'controleur-de-rotation', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('clefbd652fc2561a180f38dd89', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Tête convoyeur à bande', 'tcb', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl70978104735f0e8addcbb494', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Pied convoyeur à bande', 'PCB', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl63db65c869d07fd36d0d3422', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Elément intermédiaire & coude', 'EIC', 2, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('clac7e30f33f20287f76197c9e', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Trémie d''alimentation', 'Tremie', 3, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl081ba5565b6e9320be0c88b0', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Chariot Déverseur', 'Chariot', 4, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl164ed242e2148aac3b30c391', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Commande moteur', 'commande-moteur', 5, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl7aa47c4c4e4070ad1925ee27', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Déport de bande', 'deport-de-bande-et-controleur-de-rotation', 6, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl7c497e23a2deb7e85a2c2abe', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Contrôleur de rotation', 'controleur-de-rotation', 7, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('cl4a4fc72af5a46c4d369f535e', 'cmh0cz8e8000147s5g65vos5e', NULL, 'Contrôleur de rotation', 'controleur-de-rotation', 8, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('clce09e33e0f970116c87057c7', 'cmh0kmyh1000847s5ciu9agzo', 'cmh20z6g4001x47s5b5hturac', 'Paliers', 'paliers', 0, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
INSERT INTO public.skeleton_subcomponent_requirements (id, modeltypeid, typecomposantid, alias, familycode, position, createdat, updatedat) VALUES ('clb5e1e758aaed9688a4e7db60', 'cmh0kmyh1000847s5ciu9agzo', 'cmh4x8m4k000047nko0vwavbg', 'Roulement', 'roulement', 1, '2026-03-12 16:53:05', '2026-03-12 16:53:05');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: products; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
@@ -178,6 +286,111 @@ INSERT INTO public.composants (id, name, reference, prix, createdat, updatedat,
|
||||
INSERT INTO public.composants (id, name, reference, prix, createdat, updatedat, typecomposantid, structure, productid) VALUES ('cl9b1583768c7c9fe6cfe93a11', 'testcor', 'll', 3.00, '2026-01-25 11:28:27', '2026-01-25 11:28:27', 'cle48e33ef67853069badfc5f0', '{"path":"root","definition":[],"pieces":[{"path":"root:piece-0","definition":{"typePieceId":"cmgs1sco0002k47056yq8eyfq","typePieceLabel":"Bavette"},"selectedPieceId":"cmgs1tfza002m4705mbl0kwok"}],"products":[{"path":"root:product-0","definition":{"typeProductId":"cmhn9zrm5000247s8ds3bmpaf","familyCode":"lubrifiant"},"selectedProductId":"cmkp97us6007k1e2ws0ogux1m"}],"subcomponents":[{"path":"root:sub-0","definition":{"alias":"Tr\u00e9mie d''alimentation","typeComposantId":"cmgs0htn5002d4705tyxxiqb2","familyCode":"Tremie"},"selectedComponentId":"cmgz5h2s0009v47ff6x26cqry"}]}', 'cmkp97us6007k1e2ws0ogux1m');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: composant_piece_slots; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clcb580f2bc13c2f6ffefdfe47', 'cmgz53uvt009s47ff9v0uklr6', 'cmgrnu6zc000f470565mc8hha', 'cmgrnzbku000h4705qrj5eujb', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cleac90912bbe1571ec196c3c9', 'cmgz53uvt009s47ff9v0uklr6', 'cmgrohigo000z4705q8yvpih0', 'cmgrp2sju00144705n8etw7im', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl7b2de7e0f607306317eb6e69', 'cmgz53uvt009s47ff9v0uklr6', 'cmgrou6670011470586ipgylm', 'cmgrp1ry9001347052qn8q2yo', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl8a1a4da80556a964e7267773', 'cmgz53uvt009s47ff9v0uklr6', 'cmgroij2f00104705t6y33enk', 'cmgrp3lhv00194705f1xp8j0m', 1, 4, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl51a23d93f8c8fbf0555d8d4e', 'cmgz53uvt009s47ff9v0uklr6', 'cmgrnu6zc000f470565mc8hha', 'cmgz516t7009n47fft3nfyt34', 1, 5, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clb7caab438a04ccdbe72c7662', 'cmgz5ef4h009t47ffmxveesp0', 'cmgrzuwkj001u47057u8hej9u', 'cmgs07df2001w4705ry79yvbo', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl4e710917a9cc405aea45eae4', 'cmgz5ef4h009t47ffmxveesp0', 'cmgrzuwkj001u47057u8hej9u', 'cmgrzvdmo001v47050tvf2z88', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl0b03faf21c00093950c06434', 'cmgz5ef4h009t47ffmxveesp0', 'cmgrzuwkj001u47057u8hej9u', 'cmgs08kjb00234705wc5tytxg', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl2151fb8b1d31e27aeea1ba63', 'cmgz5ef4h009t47ffmxveesp0', 'cmgroij2f00104705t6y33enk', 'cmgrp3lhv00194705f1xp8j0m', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl1fb766a77cd4c06b0dd6f68c', 'cmgz5ef4h009t47ffmxveesp0', 'cmgroij2f00104705t6y33enk', 'cmgs0bive00274705zjmiuwzo', 1, 4, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clbb1e23854540497ce8a0cc04', 'cmgz5fsvz009u47ffkrardb1u', 'cmgroij2f00104705t6y33enk', 'cmgrp3lhv00194705f1xp8j0m', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl3410d53157d1426a7c0fee70', 'cmgz5fsvz009u47ffkrardb1u', 'cmgs0kd5o002f47053b7n8tw6', 'cmgs0nyk7002g4705rteyvw7x', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl6a3fbc2af5b82193cb7eb86c', 'cmgz5fsvz009u47ffkrardb1u', 'cmgrzuwkj001u47057u8hej9u', 'cmgrzvdmo001v47050tvf2z88', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle60fa4fef139b2e86fc5bf47', 'cmgz5fsvz009u47ffkrardb1u', 'cmgroij2f00104705t6y33enk', 'cmgs0bive00274705zjmiuwzo', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('claf42f4acd9a024263ffb60c0', 'cmgz5h2s0009v47ff6x26cqry', 'cmgs13jjp002h4705rjqzz5lh', 'cmgs14c7a002i4705t1w4qdfx', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl44cc1edd9d00de1a675810d6', 'cmgz5h2s0009v47ff6x26cqry', 'cmgs1s4pv002j470567o60oqe', 'cmgs1swl0002l4705gpyg1yyn', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl0b8eb4a8defab5cde3bccd3d', 'cmgz5h2s0009v47ff6x26cqry', 'cmgs1sco0002k47056yq8eyfq', 'cmgs1tfza002m4705mbl0kwok', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl86e3c1391165cd65555856c1', 'cmgz5h2s0009v47ff6x26cqry', 'cmgs1sco0002k47056yq8eyfq', 'cmgs1tvrs002n4705gpym7vel', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clbc122534f74362e26882cfd1', 'cmgz79ivv009x47ffeh6of72i', 'cmgroij2f00104705t6y33enk', 'cmgrp3lhv00194705f1xp8j0m', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cla3b28a687250b61e1344dcc3', 'cmgz79ivv009x47ffeh6of72i', 'cmgrzuwkj001u47057u8hej9u', 'cmgum5zm0000547ffzg8ofiqr', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl8e42ccab069fa0ecd80a2cde', 'cmgz79ivv009x47ffeh6of72i', 'cmgujpyjf002q4705j6hv1nkk', 'cmgum9bn4000847fffbazanc5', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl786b5dc9712d2488ee7d1258', 'cmgz79ivv009x47ffeh6of72i', 'cmgs1s4pv002j470567o60oqe', 'cmgyruhgm000947ffmhhrqdrl', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl168e7708c9d3c91d5a404a5e', 'cmgz79ivv009x47ffeh6of72i', 'cmgujpyjf002q4705j6hv1nkk', 'cmgyrzrbc000h47ffd670wu8j', 1, 5, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl1fd495eaa3cec6667cfab566', 'cmgz79ivv009x47ffeh6of72i', 'cmgs1s4pv002j470567o60oqe', 'cmgys0mgx000i47ffxbftvqt4', 1, 6, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clbf85161d0d8ffef1d74220b6', 'cmgz79ivv009x47ffeh6of72i', 'cm_motoreducteur_frein_01', 'cmgys1osr000j47fftblpdpu2', 1, 7, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cld72b4dce93cb64a7909f3db6', 'cmgz79ivv009x47ffeh6of72i', 'cm_motoreducteur_frein_01', 'cmgys3ugw001147ffq33udxaw', 1, 8, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle12f0173310ab9c752f8fb00', 'cmgz79ivv009x47ffeh6of72i', 'cmgukvztv002s4705kqvqjtvg', 'cmgys4a2s001847ffhqhz7zcd', 1, 9, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl5782cb522ada0d1de03c4d86', 'cmgz79ivv009x47ffeh6of72i', 'cmgukvztv002s4705kqvqjtvg', 'cmgys6k6b001h47ffuq44ze37', 1, 10, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl5d8c4c8e361ccdbaa041e5b9', 'cmgz79ivv009x47ffeh6of72i', 'cmgukxw26002t4705qz4ul929', 'cmgys7anf001m47ff0ulcp092', 1, 11, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clb1c4e1e8f6a00f1b11f24884', 'cmgz79ivv009x47ffeh6of72i', 'cmgum1ih0000347ff7bsldmnv', 'cmgys8mjl001r47ff5f8z85fs', 1, 12, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl4f4f675958d1667659498076', 'cmgz79ivv009x47ffeh6of72i', 'cmgulzr7b000247ffpr2vsput', 'cmgysatbl001u47ffu55db8gg', 1, 13, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cla3442d7af57410a430dd3c7e', 'cmgz79ivv009x47ffeh6of72i', 'cmgum1wsl000447ffa109dtag', 'cmgysj6wn002347ffgq3f98dr', 1, 14, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl0c706745220aa12658edc03c', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgytmhw0002547ffzobsmpaa', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clca63f8c3aae40ebd68cc55b7', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgytqtc8002u47ffum90ylo5', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle09fbef03d0edcd66b0a4a0c', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgyts8s9003747ffd1h8husf', 1, 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl57b44902fd7409abccdab4ae', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgytuf06003k47ffdr8lvp13', 1, 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl47ee8065661468bc113d200a', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgytewe0002447ffup09bscr', 'cmgytx2ul003x47ffhdpurtx5', 1, 4, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl6cbbd1f5046e9dfc1a89a358', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0v9k4004v47ff8apimo50', 'cmgz0w66p004w47ffvj6xcxmo', 1, 7, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle4cb39506f39efaf51af3e9e', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0v9k4004v47ff8apimo50', 'cmgz0xbx5005d47ffjkrafetg', 1, 8, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl8e47336d9326ac471ae821e7', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0v9k4004v47ff8apimo50', 'cmgz0y2aw005m47ff4zkjczei', 1, 9, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl5ae18ec62736f96d273353ec', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0v9k4004v47ff8apimo50', 'cmgz0yt33005v47ffy4p8d28z', 1, 10, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl524831b7cfa901ddc72e1728', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0zs4m006447ffq5b20ch3', 'cmgz10g67006547ffj28sqequ', 1, 11, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl4198321b8039385517c118ba', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0zs4m006447ffq5b20ch3', 'cmgz112hd006e47ffvg37mkoq', 1, 12, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl9fab43583266bb8abfe94d4d', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0zs4m006447ffq5b20ch3', 'cmgz11p1k006j47ffhjqgrnkp', 1, 13, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cled20136adaf599c72facf1a5', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz0zs4m006447ffq5b20ch3', 'cmgz128lz006o47ffwfgtag7e', 1, 14, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clca666296d8db92cabc1299d6', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz17bpz006t47ff58i3j1e1', 'cmgz17w9w006u47ffg6db710j', 1, 15, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl565591562f915536f015cf0a', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgz17bpz006t47ff58i3j1e1', 'cmgz18vw1007947ffr2wg86sa', 1, 16, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl8a5cc24bf84edeaf24b87405', 'cmgz7igun009z47ffhea93fbw', 'cmgum1wsl000447ffa109dtag', 'cmgz1c9wx007h47ffr41untmr', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle6cc6a2990d51326b7a16ac2', 'cmgz7igun009z47ffhea93fbw', 'cmgum1wsl000447ffa109dtag', 'cmgz1erci007r47ffybtdepul', 1, 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clf8105cc1fa7b62c4616cf509', 'cmh314rnj002q47s5cr3n6445', 'cmgujpyjf002q4705j6hv1nkk', 'cmh313676002d47s5li4e6qt9', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cla7987d4fe2fb2bf8ebe8ce48', 'cmh3jnikd002147zbmmnx2qw8', 'cmgujpyjf002q4705j6hv1nkk', 'cmh31ejnw003b47s548rtk8b1', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clb39042e520687e6c6b51f788', 'cl10c0924d10135c5f515378ac', 'cmgujpyjf002q4705j6hv1nkk', 'cl7b3702f04d24d87e47232a14', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl50fa6580ea24bfbcfb018cac', 'cl10eedbb54a0d2cd0fa3ce9c6', 'cmgujpyjf002q4705j6hv1nkk', 'cle1db7051dbef91fc009073a6', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl2289dce68b996d2c0b56cd49', 'cl36d84884cad86fbc92dba133', 'cmgujpyjf002q4705j6hv1nkk', 'clbf9f0070ebd464b3c309c646', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cla130de58dd731b934e1f9789', 'cl3dbac5194bc192a0589465ba', 'cmgujpyjf002q4705j6hv1nkk', 'cl50fe870a07e42759b37b511f', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl75bfb7413b5df226846ab367', 'cl4660bae41d2af254e6c3b726', 'cmgujpyjf002q4705j6hv1nkk', 'cl4e975566464253882018adcc', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl076c31311e25600aae5d07e2', 'cl54b1b4509971fde475572b29', 'cmgujpyjf002q4705j6hv1nkk', 'cl731386df55fcb9e6a01e0a63', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl4874e08b827307a1313a71b5', 'cl5a8f9656aa7e14c012f30700', 'cmgujpyjf002q4705j6hv1nkk', 'cl5ee293dc7b61feba510082a4', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl6935b95a12cd846b6a3bcbfc', 'cl5b5e336095de8d4ece81b2dc', 'cmgujpyjf002q4705j6hv1nkk', 'cldd656c6092225f53a22badc0', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clf30ffdeac20200df2d190c45', 'cl5e9c6b18bccd38517026dc1c', 'cmgujpyjf002q4705j6hv1nkk', 'clfa3147270e5e66f9b52c425e', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clb8fa63511f6514497e8c1a5f', 'cl7df36c9e7391df3d4ff46102', 'cmgujpyjf002q4705j6hv1nkk', 'cl1406ef19de58fdd1adf40221', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl2251745f51b584dd8bd73aad', 'cl7f254c23161d9c853c3e6d92', 'cmgujpyjf002q4705j6hv1nkk', 'clf16e543545eddd01b20077df', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cle99713d099f9a006931a7863', 'cl8b9b36f5a822aae21edb5a5f', 'cmgujpyjf002q4705j6hv1nkk', 'cl6667d159f6d07ba77fa79b39', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl469909161ea4061d1b514f06', 'cla833681664bb851ca61aca51', 'cmgujpyjf002q4705j6hv1nkk', 'cl8570d729efd017c12a2d5c3d', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('clf9f5aaccf25ef3a54ace17ad', 'clba5633e840726188261145f9', 'cmgujpyjf002q4705j6hv1nkk', 'clafaa71cbf49777fbb8415f19', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cld23a4f8bfc3894842b531b47', 'clbd1e945fb222e1c56dd43941', 'cmgujpyjf002q4705j6hv1nkk', 'clc08fbdcd334ed869772d98ee', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl88ea31be5c6114d3267daaad', 'clbe710810fd7ccd09811957b3', 'cmgujpyjf002q4705j6hv1nkk', 'cmh313676002d47s5li4e6qt9', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cldb0304d3089db7aced359a4f', 'cldd7f161d2cd08ee54e79161e', 'cmgujpyjf002q4705j6hv1nkk', 'cl22c13dbc4d38a1f846323ae6', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_piece_slots (id, composantid, typepieceid, selectedpieceid, quantity, position, createdat, updatedat) VALUES ('cl6e857d06dd103028af452bf6', 'cle98225ad3a32f5d8531950ef', 'cmgujpyjf002q4705j6hv1nkk', 'cl531dde45c3fc64c1a3b16ca0', 1, 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: composant_subcomponent_slots; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl114d5a6febff03d69286b6b7', 'cmgz79ivv009x47ffeh6of72i', 'Kit', 'kit', NULL, 'cmgz49bm2009547ff7ham94wz', 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl1d648f7c5a3b0ee64b22e69b', 'cmgz79ivv009x47ffeh6of72i', 'Kit', 'kit', NULL, 'cmgz4bczt009647ffzidmc8tc', 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl710c40366df98d7aeb547457', 'cmgz7igun009z47ffhea93fbw', 'Kit', 'kit', NULL, 'cmgz4equ5009747ffq665rpeb', 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl23ac52af05e90d9c7d2daba2', 'cmh0d59v5000347s561ahbept', 'Tête convoyeur à bande', 'tcb', NULL, 'cmgz53uvt009s47ff9v0uklr6', 0, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl5c1f5ff5fb762ffb1c62d02c', 'cmh0d59v5000347s561ahbept', 'Pied convoyeur à bande', 'PCB', NULL, 'cmgz5ef4h009t47ffmxveesp0', 1, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl9a90d9876471267669c5d83b', 'cmh0d59v5000347s561ahbept', 'Elément intermédiaire & coude', 'EIC', NULL, 'cmgz5fsvz009u47ffkrardb1u', 2, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('claeaa69eb72eff09abe7ef3fb', 'cmh0d59v5000347s561ahbept', 'Trémie d''alimentation', 'Tremie', NULL, 'cmgz5h2s0009v47ff6x26cqry', 3, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('clfcb74e5cc092b753c65f74f8', 'cmh0d59v5000347s561ahbept', 'Chariot Déverseur', 'Chariot', NULL, 'cmgz79ivv009x47ffeh6of72i', 4, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl8bb434545f2bca42d73d588e', 'cmh0d59v5000347s561ahbept', 'Commande moteur', 'commande-moteur', NULL, 'cmgz7fd3l009y47fff1l4g0p0', 5, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cle71559358e6346f59657d3d5', 'cmh0d59v5000347s561ahbept', 'Déport de bande', 'deport-de-bande-et-controleur-de-rotation', NULL, 'cmgz7igun009z47ffhea93fbw', 6, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl4a7d21f525c63c0581af5fc6', 'cmh0d59v5000347s561ahbept', 'Contrôleur de rotation', 'controleur-de-rotation', NULL, 'cmgz4qzap009b47ffj7ch6th7', 7, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
INSERT INTO public.composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat) VALUES ('cl9f39d9a471c6d1d9ae4c4654', 'cmh0d59v5000347s561ahbept', 'Contrôleur de rotation', 'controleur-de-rotation', NULL, 'cmgz4r99x009c47ffco9f2img', 8, '2026-03-12 17:19:23', '2026-03-12 17:19:23');
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: composant_product_slots; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: piece_products; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Data for Name: constructeurs; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
@@ -257,121 +470,121 @@ INSERT INTO public.machines (id, name, reference, prix, createdat, updatedat, si
|
||||
-- Data for Name: pieces; Type: TABLE DATA; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrp1ry9001347052qn8q2yo', 'Lame raclette', 'P40S069915', NULL, '2025-10-15 07:53:07.52', '2025-10-15 07:53:07.52', 'cmgrou6670011470586ipgylm', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrp46ud001i4705nvphpv0f', 'Palier applique', 'X21000923', NULL, '2025-10-15 07:55:00.132', '2025-10-15 07:55:00.132', 'cmgrnxlx5000g47059oyj4yuw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrp2sju00144705n8etw7im', 'Bras tendeur SE18', 'X56654', NULL, '2025-10-15 07:53:54.954', '2025-10-15 12:54:18.646', 'cmgrohigo000z4705q8yvpih0', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs08kjb00234705wc5tytxg', 'Cage d''écureuil de tension', 'W57719', NULL, '2025-10-15 13:06:20.278', '2025-10-15 13:06:20.278', 'cmgrzuwkj001u47057u8hej9u', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh3h3c82001f47zbvfmcu17d', 'Auget tôle', NULL, NULL, '2025-10-23 13:43:37.634', '2025-10-23 13:43:37.634', NULL, NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs0bive00274705zjmiuwzo', 'Rouleau1', 'X24001026', NULL, '2025-10-15 13:08:38.087', '2025-10-15 13:08:38.087', 'cmgroij2f00104705t6y33enk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs1tfza002m4705mbl0kwok', 'Bavette alimentation', 'P30W07069', NULL, '2025-10-15 13:50:33.766', '2025-10-15 13:50:33.766', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs1tvrs002n4705gpym7vel', 'Bavette centrage', 'P30W07052', NULL, '2025-10-15 13:50:54.232', '2025-10-15 13:50:54.232', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys8mjl001r47ff5f8z85fs', 'Attache rapide 19.05S', 'X10000565', NULL, '2025-10-20 06:56:49.185', '2025-10-20 06:56:49.185', 'cmgum1ih0000347ff7bsldmnv', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgytmhw0002547ffzobsmpaa', 'Moteur éléctrique', 'X50001591', NULL, '2025-10-20 07:35:35.925', '2025-10-20 07:35:35.925', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgyts8s9003747ffd1h8husf', 'Moteur éléctrique2', 'X50001596', NULL, '2025-10-20 07:40:04.088', '2025-10-20 07:40:04.088', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0rm8j004b47ffg2bh2ort', 'Réducteur1', 'X28896', NULL, '2025-10-20 10:55:32.179', '2025-10-20 10:55:32.179', 'cmgz0qu29004a47ffw1bmjr75', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0se74004o47ffnbbtu66b', 'Réducteur2', 'X15009329', NULL, '2025-10-20 10:56:08.416', '2025-10-20 10:56:08.416', 'cmgz0qu29004a47ffw1bmjr75', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh99o05y001547v7az12sk2n', 'Moteur à flasque', NULL, NULL, '2025-10-27 15:02:21.884', '2025-10-27 15:02:21.884', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0w66p004w47ffvj6xcxmo', 'Poulie1', 'X53433', NULL, '2025-10-20 10:59:04.657', '2025-10-20 10:59:26.075', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz17w9w006u47ffg6db710j', 'Courroie', 'X47067', NULL, '2025-10-20 11:08:11.684', '2025-10-20 11:08:11.684', 'cmgz17bpz006t47ff58i3j1e1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhaesmf5003v47v7bub04g9p', 'Joint à lèvre', 'J41800-RLX', 44.00, '2025-10-28 10:13:41.607', '2025-10-28 10:13:41.607', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbtjqbt0000475ultd24cp0', 'Segment d''arrêt - Circlips', 'S41800-SA2', 37.00, '2025-10-29 09:54:27.209', '2025-10-29 09:54:27.209', 'cmhalh6sa004h47v7y6pnqok2', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz18vw1007947ffr2wg86sa', 'Courroie2', 'X53480', NULL, '2025-10-20 11:08:57.84', '2025-10-20 11:08:57.84', 'cmgz17bpz006t47ff58i3j1e1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbv3kj9000t475uzbqpult7', 'Rondelle frein MB20', 'RDLMB20', 7.00, '2025-10-29 10:37:52.437', '2025-10-29 10:37:52.437', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhd56h02002a475uunel89e0', 'Manille', NULL, NULL, '2025-10-30 08:07:50.161', '2025-10-30 08:07:50.161', 'cmhd55caa0029475u1t4vg1i2', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhkr1ulr000147yv73imk2nx', 'Rondelle plate M4 RVS-A2', NULL, NULL, '2025-11-04 15:54:29.296', '2025-11-04 16:49:00.924', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh313676002d47s5li4e6qt9', 'Arbre', NULL, NULL, '2025-10-23 06:15:35.969', '2025-10-23 06:15:35.969', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh3jsgfr002n47zbadkkds7r', 'Bavette2', NULL, NULL, '2025-10-23 14:59:08.727', '2025-10-23 14:59:08.727', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrp3lhv00194705f1xp8j0m', 'Rouleau', 'X24001025', NULL, '2025-10-15 07:54:32.438', '2025-10-15 07:54:32.438', 'cmgroij2f00104705t6y33enk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs0nyk7002g4705rteyvw7x', 'Support rouleau inférieur', 'T30S06944', NULL, '2025-10-15 13:18:18.295', '2025-10-15 13:18:18.295', 'cmgs0kd5o002f47053b7n8tw6', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgum5zm0000547ffzg8ofiqr', 'Cage d''écureuil', 'W78517', NULL, '2025-10-17 08:55:43.753', '2025-10-17 08:55:43.753', 'cmgrzuwkj001u47057u8hej9u', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgum9bn4000847fffbazanc5', 'Arbre roue avant', 'H22907', NULL, '2025-10-17 08:58:19.312', '2025-10-17 08:58:19.312', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhaf1nsb004347v75uv8gmsi', 'Axe rouleau Promill', NULL, NULL, '2025-10-28 10:20:43.281', '2025-10-28 10:20:43.281', 'cmhaex3ca004247v78ymfpvpd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbu3due0007475ung88xfpm', 'Cuvette pour roulement HH 228310', 'C41800-CO2', 498.00, '2025-10-29 10:09:44.122', '2025-10-29 10:09:44.122', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgytqtc8002u47ffum90ylo5', 'Moteur éléctrique1', 'X50001593', NULL, '2025-10-20 07:38:57.415', '2025-10-20 07:38:57.415', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0xbx5005d47ffjkrafetg', 'Poulie2', 'X53446', NULL, '2025-10-20 10:59:58.743', '2025-10-20 10:59:58.743', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz1c9wx007h47ffr41untmr', 'Détecteur déport de bande', 'X23100', NULL, '2025-10-20 11:11:35.985', '2025-10-20 11:12:51.466', 'cmgum1wsl000447ffa109dtag', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbvyr210017475ubrey4eux', 'Ecrou de blocage KM20', 'ECRKM20A', 42.00, '2025-10-29 11:02:07.197', '2025-10-29 11:04:47.071', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh4xg7lb000347nkrtvqw3hi', 'Arbre Tapis émotteur', NULL, NULL, '2025-10-24 14:09:18.164', '2025-10-24 14:09:18.164', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhaagmno003647v7sfrgsb5v', 'COQUILLE nid d''abeille Promill', 'E41800ASN1P', 574.00, '2025-10-28 08:12:23.603', '2025-10-28 08:12:23.603', 'cmhaa5la2003447v7do7w3s0i', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhaf9o8j004947v7xomube6n', 'Flasque arrière rouleau Promill', 'F07700-001/5759701', 86.71, '2025-10-28 10:26:57.139', '2025-10-28 10:26:57.139', 'cmhaf6jaj004847v7cpq93sq5', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhafarpc004e47v7nqdy97xs', 'Flasque avant rouleau Promill', 'F07700-002/5759601', 115.30, '2025-10-28 10:27:48.288', '2025-10-28 10:27:48.288', 'cmhaf6jaj004847v7cpq93sq5', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbuapy5000e475utfiwfkcj', 'Cone pour roulement HH228340', 'C41800-CO2', 498.00, '2025-10-29 10:15:26.402', '2025-10-29 10:15:26.402', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhd4jjw9001q475u8x4i63jw', 'Graisseur 1/4 rouleaux Promill', NULL, NULL, '2025-10-30 07:50:00.797', '2025-10-30 07:50:00.797', 'cmhd48ipe001p475ul7ejiutq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhdb5hon002p475uif5ri4vu', 'Douille de serrage', NULL, NULL, '2025-10-30 10:55:02.086', '2025-10-30 10:55:02.086', 'cmhdb4mgx002o475udcnbd71h', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh31ejnw003b47s548rtk8b1', 'Arbre de commande', NULL, NULL, '2025-10-23 06:24:26.634', '2025-11-06 13:36:45.099', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrzvdmo001v47050tvf2z88', 'Cage d''écureuil de pied', 'W78515', NULL, '2025-10-15 12:56:04.801', '2025-10-15 13:05:49.946', 'cmgrzuwkj001u47057u8hej9u', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs14c7a002i4705t1w4qdfx', 'rouleau amortisseur avec axe', 'E1RS07058', NULL, '2025-10-15 13:31:02.469', '2025-10-15 13:31:02.469', 'cmgs13jjp002h4705rjqzz5lh', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgyruhgm000947ffmhhrqdrl', 'Galet avant chariot déverseur', 'H22698', NULL, '2025-10-20 06:45:49.386', '2025-10-20 06:45:49.386', 'cmgs1s4pv002j470567o60oqe', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgyrxcgu000a47ffxvcyyuwm', 'Palier BPF5', 'X21000919', NULL, '2025-10-20 06:48:02.91', '2025-10-20 06:48:02.91', 'cmgrnxlx5000g47059oyj4yuw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgyrzrbc000h47ffd670wu8j', 'Arbre roue arrière', 'H22908', NULL, '2025-10-20 06:49:55.463', '2025-10-20 06:49:55.463', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys0mgx000i47ffxbftvqt4', 'Galet arrière chariot déverseur', 'H22861', NULL, '2025-10-20 06:50:35.84', '2025-10-20 06:50:35.84', 'cmgs1s4pv002j470567o60oqe', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgysatbl001u47ffu55db8gg', 'Vérin éléctrique', 'X22754', NULL, '2025-10-20 06:58:31.282', '2025-10-20 06:58:31.282', 'cmgulzr7b000247ffpr2vsput', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgytuf06003k47ffdr8lvp13', 'Moteur éléctrique3', 'X50001598', NULL, '2025-10-20 07:41:45.434', '2025-10-20 07:41:45.434', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0y2aw005m47ff4zkjczei', 'Poulie3', 'X53450', NULL, '2025-10-20 11:00:32.936', '2025-10-20 11:00:32.936', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz112hd006e47ffvg37mkoq', 'Moyeu amovible2', 'X11F00653', NULL, '2025-10-20 11:02:53.136', '2025-10-20 11:02:53.136', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz1erci007r47ffybtdepul', 'Détecteur déport de bande1', 'X53294', NULL, '2025-10-20 11:13:31.891', '2025-10-20 11:13:31.891', 'cmgum1wsl000447ffa109dtag', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh3eadge001147zbworn1671', 'Bavette 2', NULL, NULL, '2025-10-23 12:25:06.974', '2025-10-23 12:25:06.974', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh3e95j4000k47zbx53n4tqv', 'Bavette1', NULL, NULL, '2025-10-23 12:24:10.047', '2025-10-23 14:58:45.061', 'cmgs1sco0002k47056yq8eyfq', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgrnzbku000h4705qrj5eujb', 'Tambour de tête', 'H57305', NULL, '2025-10-15 07:23:13.346', '2025-10-15 07:23:13.346', 'cmgrnu6zc000f470565mc8hha', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs07df2001w4705ry79yvbo', 'Cage d''écureuil de pied de tension', 'W58372', NULL, '2025-10-15 13:05:24.397', '2025-10-15 13:05:24.397', 'cmgrzuwkj001u47057u8hej9u', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgs1swl0002l4705gpyg1yyn', 'Galet releveur complet', 'W32440', NULL, '2025-10-15 13:50:08.628', '2025-10-15 13:50:08.628', 'cmgs1s4pv002j470567o60oqe', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys4a2s001847ffhqhz7zcd', 'Pignon moteur', 'H38143', NULL, '2025-10-20 06:53:26.404', '2025-10-20 06:54:46.835', 'cmgukvztv002s4705kqvqjtvg', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys6k6b001h47ffuq44ze37', 'Pignon récepteur', 'H47381', NULL, '2025-10-20 06:55:12.803', '2025-10-20 06:55:12.803', 'cmgukvztv002s4705kqvqjtvg', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys7anf001m47ff0ulcp092', 'Chaîne 19.05S', 'X10000564', NULL, '2025-10-20 06:55:47.115', '2025-10-20 06:56:20.844', 'cmgukxw26002t4705qz4ul929', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgysj6wn002347ffgq3f98dr', 'Détecteur mécanique', 'X60001690', NULL, '2025-10-20 07:05:02.134', '2025-10-20 07:05:02.134', 'cmgum1wsl000447ffa109dtag', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgytx2ul003x47ffhdpurtx5', 'Moteur éléctrique4', 'X50001600', NULL, '2025-10-20 07:43:49.676', '2025-10-20 07:43:49.676', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz0yt33005v47ffy4p8d28z', 'Poulie4', 'X41745', NULL, '2025-10-20 11:01:07.646', '2025-10-20 11:01:07.646', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz10g67006547ffj28sqequ', 'Moyeu amovible1', 'X43888', NULL, '2025-10-20 11:02:24.223', '2025-10-20 11:02:24.223', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz11p1k006j47ffhjqgrnkp', 'Moyeu amovible3', 'X41739', NULL, '2025-10-20 11:03:22.375', '2025-10-20 11:03:22.375', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz128lz006o47ffwfgtag7e', 'Moyeu amovible4', 'X11F00765', NULL, '2025-10-20 11:03:47.735', '2025-10-20 11:03:47.735', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgz516t7009n47fft3nfyt34', 'Tambour de tête1', 'H138830', NULL, '2025-10-20 12:54:57.211', '2025-10-20 12:54:57.211', 'cmgrnu6zc000f470565mc8hha', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhae5b7u003i47v7vb4qi81n', 'Joint torique R41', 'JTR41', 2.00, '2025-10-28 09:55:33.999', '2025-10-28 09:55:33.999', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhallrb1004i47v7855gvfpe', 'Segment d''étanchéïté', 'S41800-SA2', 37.00, '2025-10-28 13:24:18.658', '2025-10-28 13:24:56.572', 'cmhalh6sa004h47v7y6pnqok2', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys1osr000j47fftblpdpu2', 'Motoréducteur frein', 'X33959', NULL, '2025-10-20 06:51:25.485', '2025-10-20 06:52:25.744', NULL, NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmgys3ugw001147ffq33udxaw', 'Motoréducteur frein.', 'X108273', NULL, '2025-10-20 06:53:06.176', '2025-10-20 06:53:06.176', NULL, NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhbuq97o000l475uf73oiot0', 'Entretoise de roulements', 'E41800-000', 67.00, '2025-10-29 10:27:31.208', '2025-10-29 10:27:31.208', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhd4s3u80020475uasnc0mqj', 'Crochet de levage', NULL, NULL, '2025-10-30 07:56:39.92', '2025-10-30 07:56:39.92', 'cmhd4r5bg001z475u0f4tm9yy', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhd4syhb0025475u4sv87dyf', 'Crochet de levage avec manille', NULL, NULL, '2025-10-30 07:57:19.63', '2025-10-30 07:57:19.63', 'cmhd4r5bg001z475u0f4tm9yy', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhdiuklf002u475uadws3h04', 'Courroie XPC', NULL, NULL, '2025-10-30 14:30:29.569', '2025-10-30 14:30:29.569', 'cmgz17bpz006t47ff58i3j1e1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmh99m3e6000847v75h2m7czn', 'Réducteur emo', NULL, NULL, '2025-10-27 15:00:52.781', '2025-10-27 15:00:52.781', NULL, NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmizudzfy00021e2w2mtd9zv8', 'lame de godet 82', NULL, 192.00, '2025-12-10 10:04:09.262', '2025-12-10 10:05:37.177', 'cmizu3st800001e2waysco15j', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmizv8nzu00081e2wen6ur31b', 'Tapis', 'PF0165295', 3730.67, '2025-12-10 10:28:00.762', '2025-12-10 10:39:59.943', 'cmizup8cv00061e2w2rulkxsn', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'VIS (lame de godet) 82 M14 60mm tête fraisé', NULL, NULL, '2025-12-19 07:04:35.979', '2025-12-19 07:04:35.979', 'cmj025vi7000z1e2wyn3x6msv', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmjcpdwqs00161e2wu4juy4u2', 'Ecrou Ø 14', NULL, NULL, '2025-12-19 10:05:07.973', '2025-12-19 10:05:07.973', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmhdattcv002f475ugw514oj3', 'Poulie 8', NULL, NULL, '2025-10-30 10:45:57.343', '2026-01-14 08:04:14.29', 'cmgz0v9k4004v47ff8apimo50', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdqrkez001o1e2wtslqeazi', 'Carter presse', NULL, NULL, '2026-01-14 08:11:13.308', '2026-01-14 08:11:13.308', 'cmkdqqh8w001n1e2wzxkamd1m', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdqw1tq00231e2wxou4eu8z', 'Ecrou HM12', NULL, NULL, '2026-01-14 08:14:42.494', '2026-01-14 08:14:42.494', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdrc535002e1e2wjuucdweq', 'Moteur entrainement Presse Promill', NULL, NULL, '2026-01-14 08:27:13.217', '2026-01-14 08:27:13.217', 'cmgytewe0002447ffup09bscr', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdsbrz1002t1e2wqemldbr6', 'Vis HM 14x100', NULL, NULL, '2026-01-14 08:54:55.838', '2026-01-14 08:54:55.838', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdsdqjh00301e2wu2g4ljg2', 'Rondelle plate M24', NULL, NULL, '2026-01-14 08:56:27.294', '2026-01-14 08:56:27.294', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdsp16u003b1e2wetp787yf', 'Rondelle Grower W24', NULL, NULL, '2026-01-14 09:05:14.31', '2026-01-14 09:05:14.31', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdsrew1003m1e2wcuky5m77', 'Ecrou HM24', NULL, NULL, '2026-01-14 09:07:05.377', '2026-01-14 09:07:05.377', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdqun4a001s1e2wx123zdy1', 'Vis HM 12x35', NULL, NULL, '2026-01-14 08:13:36.778', '2026-01-14 09:15:22.132', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdt82ma003x1e2w9gkgwybf', 'Vis HM 12x30', NULL, NULL, '2026-01-14 09:20:02.626', '2026-01-14 09:20:02.626', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkdtdh5p00441e2w0g6ye4v5', 'Rondelle Grower W12', NULL, NULL, '2026-01-14 09:24:14.75', '2026-01-14 09:24:14.75', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmke1nimf004f1e2wsurzoeet', 'Vis HM 8x16', NULL, NULL, '2026-01-14 13:16:00.135', '2026-01-14 13:16:00.135', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmke3hgbc004o1e2w02his9k3', 'Vis HM 24x100', NULL, NULL, '2026-01-14 14:07:16.44', '2026-01-14 14:07:16.44', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkp7xsz1006b1e2whn582enn', 'Arbre principal Presse Promill', 'APR 80 101101', NULL, '2026-01-22 08:57:25.741', '2026-01-22 08:57:25.741', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkp80cfi006k1e2wha1v14vv', 'Joint Viton', NULL, NULL, '2026-01-22 08:59:24.27', '2026-01-22 08:59:24.27', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkp84bs0006t1e2wkokejtyn', 'Roulement à rouleaux cylindriques', NULL, NULL, '2026-01-22 09:02:30.048', '2026-01-22 09:02:30.048', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkp8mbo900751e2w2746k94i', 'Circlips E260', '390 01126000', NULL, '2026-01-22 09:16:29.721', '2026-01-22 09:16:29.721', 'cmhalh6sa004h47v7y6pnqok2', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkpkaj4400091eq6p6accs62', 'Moyeu central', 'APR 80 101103', NULL, '2026-01-22 14:43:14.884', '2026-01-22 14:43:14.884', 'cmgz0zs4m006447ffq5b20ch3', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkpkwfdh000g1eq6hpqdbqat', 'Roulement à rotule sur rouleaux', '320 41 220001', NULL, '2026-01-22 15:00:16.469', '2026-01-22 15:00:16.469', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkpnw6er000s1eq6k57kcnl8', 'Vis CHC M14x40', NULL, NULL, '2026-01-22 16:24:03.699', '2026-01-22 16:24:03.699', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkpo4gxz00121eq6o0ahmizg', 'Joint', '370 20 280000', NULL, '2026-01-22 16:30:30.6', '2026-01-22 16:30:30.6', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0nq1a004e1eq6v6ubxlfl', 'Palier tête E1 17', 'SNU516613', NULL, '2026-01-23 15:09:10.414', '2026-01-23 15:09:44.182', 'cmgrnxlx5000g47059oyj4yuw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0owbv004l1eq6pzlatzlr', 'Vis HM 14x50', NULL, NULL, '2026-01-23 15:10:05.228', '2026-01-23 15:10:05.228', 'cmkdqtcpv001r1e2wptehmkxi', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkqzl1oa002v1eq6erkt5544', 'BANDE E1 17', NULL, NULL, '2026-01-23 14:39:05.914', '2026-01-23 14:48:36.329', 'cmknus46z00551e2wf7zy706v', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0fwuo003o1eq69b1idlpw', 'Entretoise porte 2 joints', 'APR 80 101108', NULL, '2026-01-23 15:03:06', '2026-01-23 15:03:06', 'cmhabzypq003h47v7jyjjxst1', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0koqb003y1eq6a8cx8poy', 'Flasque porte 2 joints', 'APR 25 0304..', NULL, '2026-01-23 15:06:48.755', '2026-01-23 15:06:48.755', 'cmhaf6jaj004847v7cpq93sq5', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0nmsv00431eq6rcnozdes', 'Rondelle Grower W14', NULL, NULL, '2026-01-23 15:09:06.224', '2026-01-23 15:09:06.224', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr173u9004z1eq6tn4gw3h3', 'Rondelle frein MB44', NULL, NULL, '2026-01-23 15:24:14.77', '2026-01-23 15:24:14.77', 'cmhbuzjrz000s475ue0q2o2xd', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr20cpy005a1eq6nn5kmtys', 'Roulement E1 17', NULL, NULL, '2026-01-23 15:46:59.302', '2026-01-23 15:46:59.302', 'cmh9bykt8001j47v7g0oej5dw', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr253dx005k1eq65vn7evdy', 'Ecrou HM 44T', NULL, NULL, '2026-01-23 15:50:40.485', '2026-01-23 15:50:40.485', 'cmhbve5h30016475utwgpa32k', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr25xz1005v1eq6i0fib4er', 'Manchon E1 17', NULL, NULL, '2026-01-23 15:51:20.125', '2026-01-23 15:51:20.125', 'cmkr24n3x005j1eq6s9xi6obl', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cmkr0qjw5004s1eq6pen63x7j', 'Arbre E1 17', NULL, NULL, '2026-01-23 15:11:22.422', '2026-01-23 15:55:47.227', 'cmgujpyjf002q4705j6hv1nkk', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cl89d9641d47f52c5385f83d5c', 'test', 'four', 33.97, '2026-01-25 10:48:52', '2026-01-25 10:49:48', 'cl880ba34e5789668dd1c3affa', 'cmko9bmrd005m1e2w81v07kiz', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid, productids) VALUES ('cl280f805cc3e6ff4b8bde95e4', 'testjjj', NULL, NULL, '2026-01-25 11:20:44', '2026-01-25 11:20:44', 'cl880ba34e5789668dd1c3affa', 'cmko9bmrd005m1e2w81v07kiz', '["cmko9bmrd005m1e2w81v07kiz","cmkpp4fb3001i1eq6qq74ul2i"]');
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrp1ry9001347052qn8q2yo', 'Lame raclette', 'P40S069915', NULL, '2025-10-15 07:53:07.52', '2025-10-15 07:53:07.52', 'cmgrou6670011470586ipgylm', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrp46ud001i4705nvphpv0f', 'Palier applique', 'X21000923', NULL, '2025-10-15 07:55:00.132', '2025-10-15 07:55:00.132', 'cmgrnxlx5000g47059oyj4yuw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrp2sju00144705n8etw7im', 'Bras tendeur SE18', 'X56654', NULL, '2025-10-15 07:53:54.954', '2025-10-15 12:54:18.646', 'cmgrohigo000z4705q8yvpih0', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs08kjb00234705wc5tytxg', 'Cage d''écureuil de tension', 'W57719', NULL, '2025-10-15 13:06:20.278', '2025-10-15 13:06:20.278', 'cmgrzuwkj001u47057u8hej9u', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh3h3c82001f47zbvfmcu17d', 'Auget tôle', NULL, NULL, '2025-10-23 13:43:37.634', '2025-10-23 13:43:37.634', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs0bive00274705zjmiuwzo', 'Rouleau1', 'X24001026', NULL, '2025-10-15 13:08:38.087', '2025-10-15 13:08:38.087', 'cmgroij2f00104705t6y33enk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs1tfza002m4705mbl0kwok', 'Bavette alimentation', 'P30W07069', NULL, '2025-10-15 13:50:33.766', '2025-10-15 13:50:33.766', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs1tvrs002n4705gpym7vel', 'Bavette centrage', 'P30W07052', NULL, '2025-10-15 13:50:54.232', '2025-10-15 13:50:54.232', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys8mjl001r47ff5f8z85fs', 'Attache rapide 19.05S', 'X10000565', NULL, '2025-10-20 06:56:49.185', '2025-10-20 06:56:49.185', 'cmgum1ih0000347ff7bsldmnv', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgytmhw0002547ffzobsmpaa', 'Moteur éléctrique', 'X50001591', NULL, '2025-10-20 07:35:35.925', '2025-10-20 07:35:35.925', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgyts8s9003747ffd1h8husf', 'Moteur éléctrique2', 'X50001596', NULL, '2025-10-20 07:40:04.088', '2025-10-20 07:40:04.088', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0rm8j004b47ffg2bh2ort', 'Réducteur1', 'X28896', NULL, '2025-10-20 10:55:32.179', '2025-10-20 10:55:32.179', 'cmgz0qu29004a47ffw1bmjr75', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0se74004o47ffnbbtu66b', 'Réducteur2', 'X15009329', NULL, '2025-10-20 10:56:08.416', '2025-10-20 10:56:08.416', 'cmgz0qu29004a47ffw1bmjr75', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh99o05y001547v7az12sk2n', 'Moteur à flasque', NULL, NULL, '2025-10-27 15:02:21.884', '2025-10-27 15:02:21.884', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0w66p004w47ffvj6xcxmo', 'Poulie1', 'X53433', NULL, '2025-10-20 10:59:04.657', '2025-10-20 10:59:26.075', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz17w9w006u47ffg6db710j', 'Courroie', 'X47067', NULL, '2025-10-20 11:08:11.684', '2025-10-20 11:08:11.684', 'cmgz17bpz006t47ff58i3j1e1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhaesmf5003v47v7bub04g9p', 'Joint à lèvre', 'J41800-RLX', 44.00, '2025-10-28 10:13:41.607', '2025-10-28 10:13:41.607', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbtjqbt0000475ultd24cp0', 'Segment d''arrêt - Circlips', 'S41800-SA2', 37.00, '2025-10-29 09:54:27.209', '2025-10-29 09:54:27.209', 'cmhalh6sa004h47v7y6pnqok2', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz18vw1007947ffr2wg86sa', 'Courroie2', 'X53480', NULL, '2025-10-20 11:08:57.84', '2025-10-20 11:08:57.84', 'cmgz17bpz006t47ff58i3j1e1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbv3kj9000t475uzbqpult7', 'Rondelle frein MB20', 'RDLMB20', 7.00, '2025-10-29 10:37:52.437', '2025-10-29 10:37:52.437', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhd56h02002a475uunel89e0', 'Manille', NULL, NULL, '2025-10-30 08:07:50.161', '2025-10-30 08:07:50.161', 'cmhd55caa0029475u1t4vg1i2', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhkr1ulr000147yv73imk2nx', 'Rondelle plate M4 RVS-A2', NULL, NULL, '2025-11-04 15:54:29.296', '2025-11-04 16:49:00.924', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh313676002d47s5li4e6qt9', 'Arbre', NULL, NULL, '2025-10-23 06:15:35.969', '2025-10-23 06:15:35.969', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh3jsgfr002n47zbadkkds7r', 'Bavette2', NULL, NULL, '2025-10-23 14:59:08.727', '2025-10-23 14:59:08.727', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrp3lhv00194705f1xp8j0m', 'Rouleau', 'X24001025', NULL, '2025-10-15 07:54:32.438', '2025-10-15 07:54:32.438', 'cmgroij2f00104705t6y33enk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs0nyk7002g4705rteyvw7x', 'Support rouleau inférieur', 'T30S06944', NULL, '2025-10-15 13:18:18.295', '2025-10-15 13:18:18.295', 'cmgs0kd5o002f47053b7n8tw6', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgum5zm0000547ffzg8ofiqr', 'Cage d''écureuil', 'W78517', NULL, '2025-10-17 08:55:43.753', '2025-10-17 08:55:43.753', 'cmgrzuwkj001u47057u8hej9u', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgum9bn4000847fffbazanc5', 'Arbre roue avant', 'H22907', NULL, '2025-10-17 08:58:19.312', '2025-10-17 08:58:19.312', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhaf1nsb004347v75uv8gmsi', 'Axe rouleau Promill', NULL, NULL, '2025-10-28 10:20:43.281', '2025-10-28 10:20:43.281', 'cmhaex3ca004247v78ymfpvpd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbu3due0007475ung88xfpm', 'Cuvette pour roulement HH 228310', 'C41800-CO2', 498.00, '2025-10-29 10:09:44.122', '2025-10-29 10:09:44.122', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgytqtc8002u47ffum90ylo5', 'Moteur éléctrique1', 'X50001593', NULL, '2025-10-20 07:38:57.415', '2025-10-20 07:38:57.415', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0xbx5005d47ffjkrafetg', 'Poulie2', 'X53446', NULL, '2025-10-20 10:59:58.743', '2025-10-20 10:59:58.743', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz1c9wx007h47ffr41untmr', 'Détecteur déport de bande', 'X23100', NULL, '2025-10-20 11:11:35.985', '2025-10-20 11:12:51.466', 'cmgum1wsl000447ffa109dtag', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbvyr210017475ubrey4eux', 'Ecrou de blocage KM20', 'ECRKM20A', 42.00, '2025-10-29 11:02:07.197', '2025-10-29 11:04:47.071', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh4xg7lb000347nkrtvqw3hi', 'Arbre Tapis émotteur', NULL, NULL, '2025-10-24 14:09:18.164', '2025-10-24 14:09:18.164', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhaagmno003647v7sfrgsb5v', 'COQUILLE nid d''abeille Promill', 'E41800ASN1P', 574.00, '2025-10-28 08:12:23.603', '2025-10-28 08:12:23.603', 'cmhaa5la2003447v7do7w3s0i', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhaf9o8j004947v7xomube6n', 'Flasque arrière rouleau Promill', 'F07700-001/5759701', 86.71, '2025-10-28 10:26:57.139', '2025-10-28 10:26:57.139', 'cmhaf6jaj004847v7cpq93sq5', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhafarpc004e47v7nqdy97xs', 'Flasque avant rouleau Promill', 'F07700-002/5759601', 115.30, '2025-10-28 10:27:48.288', '2025-10-28 10:27:48.288', 'cmhaf6jaj004847v7cpq93sq5', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbuapy5000e475utfiwfkcj', 'Cone pour roulement HH228340', 'C41800-CO2', 498.00, '2025-10-29 10:15:26.402', '2025-10-29 10:15:26.402', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhd4jjw9001q475u8x4i63jw', 'Graisseur 1/4 rouleaux Promill', NULL, NULL, '2025-10-30 07:50:00.797', '2025-10-30 07:50:00.797', 'cmhd48ipe001p475ul7ejiutq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhdb5hon002p475uif5ri4vu', 'Douille de serrage', NULL, NULL, '2025-10-30 10:55:02.086', '2025-10-30 10:55:02.086', 'cmhdb4mgx002o475udcnbd71h', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh31ejnw003b47s548rtk8b1', 'Arbre de commande', NULL, NULL, '2025-10-23 06:24:26.634', '2025-11-06 13:36:45.099', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrzvdmo001v47050tvf2z88', 'Cage d''écureuil de pied', 'W78515', NULL, '2025-10-15 12:56:04.801', '2025-10-15 13:05:49.946', 'cmgrzuwkj001u47057u8hej9u', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs14c7a002i4705t1w4qdfx', 'rouleau amortisseur avec axe', 'E1RS07058', NULL, '2025-10-15 13:31:02.469', '2025-10-15 13:31:02.469', 'cmgs13jjp002h4705rjqzz5lh', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgyruhgm000947ffmhhrqdrl', 'Galet avant chariot déverseur', 'H22698', NULL, '2025-10-20 06:45:49.386', '2025-10-20 06:45:49.386', 'cmgs1s4pv002j470567o60oqe', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgyrxcgu000a47ffxvcyyuwm', 'Palier BPF5', 'X21000919', NULL, '2025-10-20 06:48:02.91', '2025-10-20 06:48:02.91', 'cmgrnxlx5000g47059oyj4yuw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgyrzrbc000h47ffd670wu8j', 'Arbre roue arrière', 'H22908', NULL, '2025-10-20 06:49:55.463', '2025-10-20 06:49:55.463', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys0mgx000i47ffxbftvqt4', 'Galet arrière chariot déverseur', 'H22861', NULL, '2025-10-20 06:50:35.84', '2025-10-20 06:50:35.84', 'cmgs1s4pv002j470567o60oqe', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgysatbl001u47ffu55db8gg', 'Vérin éléctrique', 'X22754', NULL, '2025-10-20 06:58:31.282', '2025-10-20 06:58:31.282', 'cmgulzr7b000247ffpr2vsput', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgytuf06003k47ffdr8lvp13', 'Moteur éléctrique3', 'X50001598', NULL, '2025-10-20 07:41:45.434', '2025-10-20 07:41:45.434', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0y2aw005m47ff4zkjczei', 'Poulie3', 'X53450', NULL, '2025-10-20 11:00:32.936', '2025-10-20 11:00:32.936', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz112hd006e47ffvg37mkoq', 'Moyeu amovible2', 'X11F00653', NULL, '2025-10-20 11:02:53.136', '2025-10-20 11:02:53.136', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz1erci007r47ffybtdepul', 'Détecteur déport de bande1', 'X53294', NULL, '2025-10-20 11:13:31.891', '2025-10-20 11:13:31.891', 'cmgum1wsl000447ffa109dtag', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh3eadge001147zbworn1671', 'Bavette 2', NULL, NULL, '2025-10-23 12:25:06.974', '2025-10-23 12:25:06.974', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh3e95j4000k47zbx53n4tqv', 'Bavette1', NULL, NULL, '2025-10-23 12:24:10.047', '2025-10-23 14:58:45.061', 'cmgs1sco0002k47056yq8eyfq', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgrnzbku000h4705qrj5eujb', 'Tambour de tête', 'H57305', NULL, '2025-10-15 07:23:13.346', '2025-10-15 07:23:13.346', 'cmgrnu6zc000f470565mc8hha', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs07df2001w4705ry79yvbo', 'Cage d''écureuil de pied de tension', 'W58372', NULL, '2025-10-15 13:05:24.397', '2025-10-15 13:05:24.397', 'cmgrzuwkj001u47057u8hej9u', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgs1swl0002l4705gpyg1yyn', 'Galet releveur complet', 'W32440', NULL, '2025-10-15 13:50:08.628', '2025-10-15 13:50:08.628', 'cmgs1s4pv002j470567o60oqe', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys4a2s001847ffhqhz7zcd', 'Pignon moteur', 'H38143', NULL, '2025-10-20 06:53:26.404', '2025-10-20 06:54:46.835', 'cmgukvztv002s4705kqvqjtvg', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys6k6b001h47ffuq44ze37', 'Pignon récepteur', 'H47381', NULL, '2025-10-20 06:55:12.803', '2025-10-20 06:55:12.803', 'cmgukvztv002s4705kqvqjtvg', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys7anf001m47ff0ulcp092', 'Chaîne 19.05S', 'X10000564', NULL, '2025-10-20 06:55:47.115', '2025-10-20 06:56:20.844', 'cmgukxw26002t4705qz4ul929', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgysj6wn002347ffgq3f98dr', 'Détecteur mécanique', 'X60001690', NULL, '2025-10-20 07:05:02.134', '2025-10-20 07:05:02.134', 'cmgum1wsl000447ffa109dtag', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgytx2ul003x47ffhdpurtx5', 'Moteur éléctrique4', 'X50001600', NULL, '2025-10-20 07:43:49.676', '2025-10-20 07:43:49.676', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz0yt33005v47ffy4p8d28z', 'Poulie4', 'X41745', NULL, '2025-10-20 11:01:07.646', '2025-10-20 11:01:07.646', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz10g67006547ffj28sqequ', 'Moyeu amovible1', 'X43888', NULL, '2025-10-20 11:02:24.223', '2025-10-20 11:02:24.223', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz11p1k006j47ffhjqgrnkp', 'Moyeu amovible3', 'X41739', NULL, '2025-10-20 11:03:22.375', '2025-10-20 11:03:22.375', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz128lz006o47ffwfgtag7e', 'Moyeu amovible4', 'X11F00765', NULL, '2025-10-20 11:03:47.735', '2025-10-20 11:03:47.735', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgz516t7009n47fft3nfyt34', 'Tambour de tête1', 'H138830', NULL, '2025-10-20 12:54:57.211', '2025-10-20 12:54:57.211', 'cmgrnu6zc000f470565mc8hha', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhae5b7u003i47v7vb4qi81n', 'Joint torique R41', 'JTR41', 2.00, '2025-10-28 09:55:33.999', '2025-10-28 09:55:33.999', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhallrb1004i47v7855gvfpe', 'Segment d''étanchéïté', 'S41800-SA2', 37.00, '2025-10-28 13:24:18.658', '2025-10-28 13:24:56.572', 'cmhalh6sa004h47v7y6pnqok2', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys1osr000j47fftblpdpu2', 'Motoréducteur frein', 'X33959', NULL, '2025-10-20 06:51:25.485', '2025-10-20 06:52:25.744', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmgys3ugw001147ffq33udxaw', 'Motoréducteur frein.', 'X108273', NULL, '2025-10-20 06:53:06.176', '2025-10-20 06:53:06.176', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhbuq97o000l475uf73oiot0', 'Entretoise de roulements', 'E41800-000', 67.00, '2025-10-29 10:27:31.208', '2025-10-29 10:27:31.208', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhd4s3u80020475uasnc0mqj', 'Crochet de levage', NULL, NULL, '2025-10-30 07:56:39.92', '2025-10-30 07:56:39.92', 'cmhd4r5bg001z475u0f4tm9yy', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhd4syhb0025475u4sv87dyf', 'Crochet de levage avec manille', NULL, NULL, '2025-10-30 07:57:19.63', '2025-10-30 07:57:19.63', 'cmhd4r5bg001z475u0f4tm9yy', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhdiuklf002u475uadws3h04', 'Courroie XPC', NULL, NULL, '2025-10-30 14:30:29.569', '2025-10-30 14:30:29.569', 'cmgz17bpz006t47ff58i3j1e1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmh99m3e6000847v75h2m7czn', 'Réducteur emo', NULL, NULL, '2025-10-27 15:00:52.781', '2025-10-27 15:00:52.781', NULL, NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmizudzfy00021e2w2mtd9zv8', 'lame de godet 82', NULL, 192.00, '2025-12-10 10:04:09.262', '2025-12-10 10:05:37.177', 'cmizu3st800001e2waysco15j', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmizv8nzu00081e2wen6ur31b', 'Tapis', 'PF0165295', 3730.67, '2025-12-10 10:28:00.762', '2025-12-10 10:39:59.943', 'cmizup8cv00061e2w2rulkxsn', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'VIS (lame de godet) 82 M14 60mm tête fraisé', NULL, NULL, '2025-12-19 07:04:35.979', '2025-12-19 07:04:35.979', 'cmj025vi7000z1e2wyn3x6msv', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmjcpdwqs00161e2wu4juy4u2', 'Ecrou Ø 14', NULL, NULL, '2025-12-19 10:05:07.973', '2025-12-19 10:05:07.973', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmhdattcv002f475ugw514oj3', 'Poulie 8', NULL, NULL, '2025-10-30 10:45:57.343', '2026-01-14 08:04:14.29', 'cmgz0v9k4004v47ff8apimo50', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdqrkez001o1e2wtslqeazi', 'Carter presse', NULL, NULL, '2026-01-14 08:11:13.308', '2026-01-14 08:11:13.308', 'cmkdqqh8w001n1e2wzxkamd1m', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdqw1tq00231e2wxou4eu8z', 'Ecrou HM12', NULL, NULL, '2026-01-14 08:14:42.494', '2026-01-14 08:14:42.494', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdrc535002e1e2wjuucdweq', 'Moteur entrainement Presse Promill', NULL, NULL, '2026-01-14 08:27:13.217', '2026-01-14 08:27:13.217', 'cmgytewe0002447ffup09bscr', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdsbrz1002t1e2wqemldbr6', 'Vis HM 14x100', NULL, NULL, '2026-01-14 08:54:55.838', '2026-01-14 08:54:55.838', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdsdqjh00301e2wu2g4ljg2', 'Rondelle plate M24', NULL, NULL, '2026-01-14 08:56:27.294', '2026-01-14 08:56:27.294', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdsp16u003b1e2wetp787yf', 'Rondelle Grower W24', NULL, NULL, '2026-01-14 09:05:14.31', '2026-01-14 09:05:14.31', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdsrew1003m1e2wcuky5m77', 'Ecrou HM24', NULL, NULL, '2026-01-14 09:07:05.377', '2026-01-14 09:07:05.377', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdqun4a001s1e2wx123zdy1', 'Vis HM 12x35', NULL, NULL, '2026-01-14 08:13:36.778', '2026-01-14 09:15:22.132', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdt82ma003x1e2w9gkgwybf', 'Vis HM 12x30', NULL, NULL, '2026-01-14 09:20:02.626', '2026-01-14 09:20:02.626', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkdtdh5p00441e2w0g6ye4v5', 'Rondelle Grower W12', NULL, NULL, '2026-01-14 09:24:14.75', '2026-01-14 09:24:14.75', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmke1nimf004f1e2wsurzoeet', 'Vis HM 8x16', NULL, NULL, '2026-01-14 13:16:00.135', '2026-01-14 13:16:00.135', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmke3hgbc004o1e2w02his9k3', 'Vis HM 24x100', NULL, NULL, '2026-01-14 14:07:16.44', '2026-01-14 14:07:16.44', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkp7xsz1006b1e2whn582enn', 'Arbre principal Presse Promill', 'APR 80 101101', NULL, '2026-01-22 08:57:25.741', '2026-01-22 08:57:25.741', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkp80cfi006k1e2wha1v14vv', 'Joint Viton', NULL, NULL, '2026-01-22 08:59:24.27', '2026-01-22 08:59:24.27', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkp84bs0006t1e2wkokejtyn', 'Roulement à rouleaux cylindriques', NULL, NULL, '2026-01-22 09:02:30.048', '2026-01-22 09:02:30.048', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkp8mbo900751e2w2746k94i', 'Circlips E260', '390 01126000', NULL, '2026-01-22 09:16:29.721', '2026-01-22 09:16:29.721', 'cmhalh6sa004h47v7y6pnqok2', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkpkaj4400091eq6p6accs62', 'Moyeu central', 'APR 80 101103', NULL, '2026-01-22 14:43:14.884', '2026-01-22 14:43:14.884', 'cmgz0zs4m006447ffq5b20ch3', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkpkwfdh000g1eq6hpqdbqat', 'Roulement à rotule sur rouleaux', '320 41 220001', NULL, '2026-01-22 15:00:16.469', '2026-01-22 15:00:16.469', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkpnw6er000s1eq6k57kcnl8', 'Vis CHC M14x40', NULL, NULL, '2026-01-22 16:24:03.699', '2026-01-22 16:24:03.699', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkpo4gxz00121eq6o0ahmizg', 'Joint', '370 20 280000', NULL, '2026-01-22 16:30:30.6', '2026-01-22 16:30:30.6', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0nq1a004e1eq6v6ubxlfl', 'Palier tête E1 17', 'SNU516613', NULL, '2026-01-23 15:09:10.414', '2026-01-23 15:09:44.182', 'cmgrnxlx5000g47059oyj4yuw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0owbv004l1eq6pzlatzlr', 'Vis HM 14x50', NULL, NULL, '2026-01-23 15:10:05.228', '2026-01-23 15:10:05.228', 'cmkdqtcpv001r1e2wptehmkxi', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkqzl1oa002v1eq6erkt5544', 'BANDE E1 17', NULL, NULL, '2026-01-23 14:39:05.914', '2026-01-23 14:48:36.329', 'cmknus46z00551e2wf7zy706v', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0fwuo003o1eq69b1idlpw', 'Entretoise porte 2 joints', 'APR 80 101108', NULL, '2026-01-23 15:03:06', '2026-01-23 15:03:06', 'cmhabzypq003h47v7jyjjxst1', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0koqb003y1eq6a8cx8poy', 'Flasque porte 2 joints', 'APR 25 0304..', NULL, '2026-01-23 15:06:48.755', '2026-01-23 15:06:48.755', 'cmhaf6jaj004847v7cpq93sq5', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0nmsv00431eq6rcnozdes', 'Rondelle Grower W14', NULL, NULL, '2026-01-23 15:09:06.224', '2026-01-23 15:09:06.224', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr173u9004z1eq6tn4gw3h3', 'Rondelle frein MB44', NULL, NULL, '2026-01-23 15:24:14.77', '2026-01-23 15:24:14.77', 'cmhbuzjrz000s475ue0q2o2xd', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr20cpy005a1eq6nn5kmtys', 'Roulement E1 17', NULL, NULL, '2026-01-23 15:46:59.302', '2026-01-23 15:46:59.302', 'cmh9bykt8001j47v7g0oej5dw', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr253dx005k1eq65vn7evdy', 'Ecrou HM 44T', NULL, NULL, '2026-01-23 15:50:40.485', '2026-01-23 15:50:40.485', 'cmhbve5h30016475utwgpa32k', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr25xz1005v1eq6i0fib4er', 'Manchon E1 17', NULL, NULL, '2026-01-23 15:51:20.125', '2026-01-23 15:51:20.125', 'cmkr24n3x005j1eq6s9xi6obl', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cmkr0qjw5004s1eq6pen63x7j', 'Arbre E1 17', NULL, NULL, '2026-01-23 15:11:22.422', '2026-01-23 15:55:47.227', 'cmgujpyjf002q4705j6hv1nkk', NULL);
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cl89d9641d47f52c5385f83d5c', 'test', 'four', 33.97, '2026-01-25 10:48:52', '2026-01-25 10:49:48', 'cl880ba34e5789668dd1c3affa', 'cmko9bmrd005m1e2w81v07kiz');
|
||||
INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, typepieceid, productid) VALUES ('cl280f805cc3e6ff4b8bde95e4', 'testjjj', NULL, NULL, '2026-01-25 11:20:44', '2026-01-25 11:20:44', 'cl880ba34e5789668dd1c3affa', 'cmko9bmrd005m1e2w81v07kiz');
|
||||
|
||||
|
||||
--
|
||||
|
||||
26
migrations/Version20260309150000.php
Normal file
26
migrations/Version20260309150000.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260309150000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add quantity column to machine_piece_links table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE machine_piece_links ADD COLUMN IF NOT EXISTS quantity INTEGER NOT NULL DEFAULT 1');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE machine_piece_links DROP COLUMN IF EXISTS quantity');
|
||||
}
|
||||
}
|
||||
222
migrations/Version20260312170000.php
Normal file
222
migrations/Version20260312170000.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260312170000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create skeleton requirement tables (IF NOT EXISTS) and migrate JSON data from ModelType skeleton columns';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// ── Table creation (idempotent) ──────────────────────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS skeleton_piece_requirements (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"modeltypeid" VARCHAR(36) NOT NULL,
|
||||
"typepieceid" VARCHAR(36) NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS skeleton_product_requirements (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"modeltypeid" VARCHAR(36) NOT NULL,
|
||||
"typeproductid" VARCHAR(36) NOT NULL,
|
||||
"familycode" VARCHAR(255) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS skeleton_subcomponent_requirements (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"modeltypeid" VARCHAR(36) NOT NULL,
|
||||
alias VARCHAR(255) NOT NULL,
|
||||
"familycode" VARCHAR(255) NOT NULL,
|
||||
"typecomposantid" VARCHAR(36) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
// ── Indexes (idempotent) ─────────────────────────────────────────────
|
||||
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_piece_req_model ON skeleton_piece_requirements("modeltypeid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_piece_req_type ON skeleton_piece_requirements("typepieceid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_prod_req_model ON skeleton_product_requirements("modeltypeid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_prod_req_type ON skeleton_product_requirements("typeproductid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_sub_req_model ON skeleton_subcomponent_requirements("modeltypeid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_skel_sub_req_typecomp ON skeleton_subcomponent_requirements("typecomposantid")');
|
||||
|
||||
// ── Foreign keys (idempotent via DO $$ block) ────────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_piece_model') THEN
|
||||
ALTER TABLE skeleton_piece_requirements
|
||||
ADD CONSTRAINT fk_skel_piece_model
|
||||
FOREIGN KEY ("modeltypeid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_piece_type') THEN
|
||||
ALTER TABLE skeleton_piece_requirements
|
||||
ADD CONSTRAINT fk_skel_piece_type
|
||||
FOREIGN KEY ("typepieceid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_prod_model') THEN
|
||||
ALTER TABLE skeleton_product_requirements
|
||||
ADD CONSTRAINT fk_skel_prod_model
|
||||
FOREIGN KEY ("modeltypeid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_prod_type') THEN
|
||||
ALTER TABLE skeleton_product_requirements
|
||||
ADD CONSTRAINT fk_skel_prod_type
|
||||
FOREIGN KEY ("typeproductid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_sub_model') THEN
|
||||
ALTER TABLE skeleton_subcomponent_requirements
|
||||
ADD CONSTRAINT fk_skel_sub_model
|
||||
FOREIGN KEY ("modeltypeid") REFERENCES model_types (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_skel_sub_typecomp') THEN
|
||||
ALTER TABLE skeleton_subcomponent_requirements
|
||||
ADD CONSTRAINT fk_skel_sub_typecomp
|
||||
FOREIGN KEY ("typecomposantid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// ── Data migration: componentSkeleton.pieces → skeleton_piece_requirements ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO skeleton_piece_requirements (id, "modeltypeid", "typepieceid", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
mt.id,
|
||||
(piece->>'typePieceId'),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM model_types mt,
|
||||
LATERAL jsonb_array_elements(mt.componentskeleton::jsonb->'pieces') WITH ORDINALITY AS t(piece, ordinality)
|
||||
WHERE mt.category = 'COMPONENT'
|
||||
AND mt.componentskeleton IS NOT NULL
|
||||
AND mt.componentskeleton::jsonb->'pieces' IS NOT NULL
|
||||
AND jsonb_array_length(mt.componentskeleton::jsonb->'pieces') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM skeleton_piece_requirements spr WHERE spr."modeltypeid" = mt.id)
|
||||
AND EXISTS (SELECT 1 FROM model_types ref WHERE ref.id = (piece->>'typePieceId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: componentSkeleton.products → skeleton_product_requirements ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO skeleton_product_requirements (id, "modeltypeid", "typeproductid", "familycode", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
mt.id,
|
||||
(product->>'typeProductId'),
|
||||
(product->>'familyCode'),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM model_types mt,
|
||||
LATERAL jsonb_array_elements(mt.componentskeleton::jsonb->'products') WITH ORDINALITY AS t(product, ordinality)
|
||||
WHERE mt.category = 'COMPONENT'
|
||||
AND mt.componentskeleton IS NOT NULL
|
||||
AND mt.componentskeleton::jsonb->'products' IS NOT NULL
|
||||
AND jsonb_array_length(mt.componentskeleton::jsonb->'products') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM skeleton_product_requirements spr WHERE spr."modeltypeid" = mt.id)
|
||||
AND EXISTS (SELECT 1 FROM model_types ref WHERE ref.id = (product->>'typeProductId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: pieceSkeleton.products → skeleton_product_requirements ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO skeleton_product_requirements (id, "modeltypeid", "typeproductid", "familycode", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
mt.id,
|
||||
(product->>'typeProductId'),
|
||||
(product->>'familyCode'),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM model_types mt,
|
||||
LATERAL jsonb_array_elements(mt.pieceskeleton::jsonb->'products') WITH ORDINALITY AS t(product, ordinality)
|
||||
WHERE mt.category = 'PIECE'
|
||||
AND mt.pieceskeleton IS NOT NULL
|
||||
AND mt.pieceskeleton::jsonb->'products' IS NOT NULL
|
||||
AND jsonb_array_length(mt.pieceskeleton::jsonb->'products') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM skeleton_product_requirements spr WHERE spr."modeltypeid" = mt.id)
|
||||
AND EXISTS (SELECT 1 FROM model_types ref WHERE ref.id = (product->>'typeProductId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: componentSkeleton.subcomponents → skeleton_subcomponent_requirements ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO skeleton_subcomponent_requirements (id, "modeltypeid", alias, "familycode", "typecomposantid", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
mt.id,
|
||||
COALESCE(sub->>'alias', ''),
|
||||
COALESCE(sub->>'familyCode', ''),
|
||||
NULLIF(sub->>'typeComposantId', ''),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM model_types mt,
|
||||
LATERAL jsonb_array_elements(mt.componentskeleton::jsonb->'subcomponents') WITH ORDINALITY AS t(sub, ordinality)
|
||||
WHERE mt.category = 'COMPONENT'
|
||||
AND mt.componentskeleton IS NOT NULL
|
||||
AND mt.componentskeleton::jsonb->'subcomponents' IS NOT NULL
|
||||
AND jsonb_array_length(mt.componentskeleton::jsonb->'subcomponents') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM skeleton_subcomponent_requirements ssr WHERE ssr."modeltypeid" = mt.id)
|
||||
AND (NULLIF(sub->>'typeComposantId', '') IS NULL OR EXISTS (SELECT 1 FROM model_types ref WHERE ref.id = (sub->>'typeComposantId')))
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS skeleton_subcomponent_requirements');
|
||||
$this->addSql('DROP TABLE IF EXISTS skeleton_product_requirements');
|
||||
$this->addSql('DROP TABLE IF EXISTS skeleton_piece_requirements');
|
||||
}
|
||||
}
|
||||
47
migrations/Version20260312171810.php
Normal file
47
migrations/Version20260312171810.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260312171810 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create piece_products join table and migrate data from Piece.productIds JSON column';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE IF NOT EXISTS piece_products (piece_id VARCHAR(36) NOT NULL, product_id VARCHAR(36) NOT NULL, PRIMARY KEY (piece_id, product_id))');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_87C835B5C40FCFA8 ON piece_products (piece_id)');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS IDX_87C835B54584665A ON piece_products (product_id)');
|
||||
$this->addSql('ALTER TABLE piece_products DROP CONSTRAINT IF EXISTS FK_87C835B5C40FCFA8');
|
||||
$this->addSql('ALTER TABLE piece_products ADD CONSTRAINT FK_87C835B5C40FCFA8 FOREIGN KEY (piece_id) REFERENCES pieces (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
$this->addSql('ALTER TABLE piece_products DROP CONSTRAINT IF EXISTS FK_87C835B54584665A');
|
||||
$this->addSql('ALTER TABLE piece_products ADD CONSTRAINT FK_87C835B54584665A FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE NOT DEFERRABLE');
|
||||
|
||||
// Migrate Piece.productIds JSON array → piece_products join table
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO piece_products (piece_id, product_id)
|
||||
SELECT DISTINCT p.id, pid.value
|
||||
FROM pieces p,
|
||||
LATERAL jsonb_array_elements_text(p.productids::jsonb) AS pid(value)
|
||||
WHERE p.productids IS NOT NULL
|
||||
AND p.productids::jsonb != '[]'::jsonb
|
||||
AND jsonb_array_length(p.productids::jsonb) > 0
|
||||
AND EXISTS (SELECT 1 FROM products pr WHERE pr.id = pid.value)
|
||||
AND NOT EXISTS (SELECT 1 FROM piece_products pp WHERE pp.piece_id = p.id AND pp.product_id = pid.value)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE piece_products DROP CONSTRAINT IF EXISTS FK_87C835B5C40FCFA8');
|
||||
$this->addSql('ALTER TABLE piece_products DROP CONSTRAINT IF EXISTS FK_87C835B54584665A');
|
||||
$this->addSql('DROP TABLE IF EXISTS piece_products');
|
||||
}
|
||||
}
|
||||
35
migrations/Version20260312180000.php
Normal file
35
migrations/Version20260312180000.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Drop skeleton JSON columns from model_types — data now lives in
|
||||
* skeleton_piece_requirements, skeleton_product_requirements,
|
||||
* skeleton_subcomponent_requirements and custom_fields tables.
|
||||
*/
|
||||
final class Version20260312180000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop componentSkeleton, pieceSkeleton, productSkeleton JSON columns from model_types';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS componentskeleton');
|
||||
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS pieceskeleton');
|
||||
$this->addSql('ALTER TABLE model_types DROP COLUMN IF EXISTS productskeleton');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS componentskeleton JSON DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS pieceskeleton JSON DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE model_types ADD COLUMN IF NOT EXISTS productskeleton JSON DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
248
migrations/Version20260312190000.php
Normal file
248
migrations/Version20260312190000.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Create composant slot tables and migrate existing JSON data from composant.structure.
|
||||
*/
|
||||
final class Version20260312190000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create composant_piece_slots, composant_subcomponent_slots, composant_product_slots tables and migrate data from composant.structure JSON';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// ── Table creation (idempotent) ──────────────────────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS composant_piece_slots (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"composantid" VARCHAR(36) NOT NULL,
|
||||
"typepieceid" VARCHAR(36) DEFAULT NULL,
|
||||
"selectedpieceid" VARCHAR(36) DEFAULT NULL,
|
||||
quantity INT NOT NULL DEFAULT 1,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS composant_subcomponent_slots (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"composantid" VARCHAR(36) NOT NULL,
|
||||
alias VARCHAR(255) DEFAULT NULL,
|
||||
"familycode" VARCHAR(255) DEFAULT NULL,
|
||||
"typecomposantid" VARCHAR(36) DEFAULT NULL,
|
||||
"selectedcomposantid" VARCHAR(36) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS composant_product_slots (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"composantid" VARCHAR(36) NOT NULL,
|
||||
"typeproductid" VARCHAR(36) DEFAULT NULL,
|
||||
"selectedproductid" VARCHAR(36) DEFAULT NULL,
|
||||
"familycode" VARCHAR(255) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
// ── Indexes (idempotent) ─────────────────────────────────────────────
|
||||
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_piece_slot_composant ON composant_piece_slots("composantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_piece_slot_piece ON composant_piece_slots("selectedpieceid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_piece_slot_type ON composant_piece_slots("typepieceid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_sub_slot_composant ON composant_subcomponent_slots("composantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_sub_slot_typecomp ON composant_subcomponent_slots("typecomposantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_sub_slot_selected ON composant_subcomponent_slots("selectedcomposantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_prod_slot_composant ON composant_product_slots("composantid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_prod_slot_type ON composant_product_slots("typeproductid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_comp_prod_slot_selected ON composant_product_slots("selectedproductid")');
|
||||
|
||||
// ── Foreign keys (idempotent via DO $$ block) ────────────────────────
|
||||
|
||||
// composant_piece_slots FKs
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_piece_slot_composant') THEN
|
||||
ALTER TABLE composant_piece_slots
|
||||
ADD CONSTRAINT fk_comp_piece_slot_composant
|
||||
FOREIGN KEY ("composantid") REFERENCES composants (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_piece_slot_type') THEN
|
||||
ALTER TABLE composant_piece_slots
|
||||
ADD CONSTRAINT fk_comp_piece_slot_type
|
||||
FOREIGN KEY ("typepieceid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_piece_slot_piece') THEN
|
||||
ALTER TABLE composant_piece_slots
|
||||
ADD CONSTRAINT fk_comp_piece_slot_piece
|
||||
FOREIGN KEY ("selectedpieceid") REFERENCES pieces (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// composant_subcomponent_slots FKs
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_sub_slot_composant') THEN
|
||||
ALTER TABLE composant_subcomponent_slots
|
||||
ADD CONSTRAINT fk_comp_sub_slot_composant
|
||||
FOREIGN KEY ("composantid") REFERENCES composants (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_sub_slot_typecomp') THEN
|
||||
ALTER TABLE composant_subcomponent_slots
|
||||
ADD CONSTRAINT fk_comp_sub_slot_typecomp
|
||||
FOREIGN KEY ("typecomposantid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_sub_slot_selected') THEN
|
||||
ALTER TABLE composant_subcomponent_slots
|
||||
ADD CONSTRAINT fk_comp_sub_slot_selected
|
||||
FOREIGN KEY ("selectedcomposantid") REFERENCES composants (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// composant_product_slots FKs
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_prod_slot_composant') THEN
|
||||
ALTER TABLE composant_product_slots
|
||||
ADD CONSTRAINT fk_comp_prod_slot_composant
|
||||
FOREIGN KEY ("composantid") REFERENCES composants (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_prod_slot_type') THEN
|
||||
ALTER TABLE composant_product_slots
|
||||
ADD CONSTRAINT fk_comp_prod_slot_type
|
||||
FOREIGN KEY ("typeproductid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_comp_prod_slot_selected') THEN
|
||||
ALTER TABLE composant_product_slots
|
||||
ADD CONSTRAINT fk_comp_prod_slot_selected
|
||||
FOREIGN KEY ("selectedproductid") REFERENCES products (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// ── Data migration: composant.structure.pieces → composant_piece_slots ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO composant_piece_slots (id, "composantid", "typepieceid", "selectedpieceid", quantity, position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
c.id,
|
||||
NULLIF(piece->'definition'->>'typePieceId', ''),
|
||||
NULLIF(piece->>'selectedPieceId', ''),
|
||||
1,
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM composants c,
|
||||
LATERAL jsonb_array_elements(c.structure::jsonb->'pieces') WITH ORDINALITY AS t(piece, ordinality)
|
||||
WHERE c.structure IS NOT NULL
|
||||
AND (c.structure::jsonb->'pieces') IS NOT NULL
|
||||
AND jsonb_array_length(c.structure::jsonb->'pieces') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM composant_piece_slots cps WHERE cps."composantid" = c.id)
|
||||
AND (NULLIF(piece->'definition'->>'typePieceId', '') IS NULL OR EXISTS (SELECT 1 FROM model_types mt WHERE mt.id = piece->'definition'->>'typePieceId'))
|
||||
AND (NULLIF(piece->>'selectedPieceId', '') IS NULL OR EXISTS (SELECT 1 FROM pieces p WHERE p.id = piece->>'selectedPieceId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: composant.structure.subcomponents → composant_subcomponent_slots ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO composant_subcomponent_slots (id, "composantid", alias, "familycode", "typecomposantid", "selectedcomposantid", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
c.id,
|
||||
COALESCE(sub->'definition'->>'alias', ''),
|
||||
COALESCE(sub->'definition'->>'familyCode', ''),
|
||||
NULLIF(sub->'definition'->>'typeComposantId', ''),
|
||||
NULLIF(sub->>'selectedComponentId', ''),
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM composants c,
|
||||
LATERAL jsonb_array_elements(c.structure::jsonb->'subcomponents') WITH ORDINALITY AS t(sub, ordinality)
|
||||
WHERE c.structure IS NOT NULL
|
||||
AND (c.structure::jsonb->'subcomponents') IS NOT NULL
|
||||
AND jsonb_array_length(c.structure::jsonb->'subcomponents') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM composant_subcomponent_slots css WHERE css."composantid" = c.id)
|
||||
AND (NULLIF(sub->'definition'->>'typeComposantId', '') IS NULL OR EXISTS (SELECT 1 FROM model_types mt WHERE mt.id = sub->'definition'->>'typeComposantId'))
|
||||
AND (NULLIF(sub->>'selectedComponentId', '') IS NULL OR EXISTS (SELECT 1 FROM composants sc WHERE sc.id = sub->>'selectedComponentId'))
|
||||
SQL);
|
||||
|
||||
// ── Data migration: composant.structure.products → composant_product_slots ──
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
INSERT INTO composant_product_slots (id, "composantid", "typeproductid", "selectedproductid", "familycode", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
c.id,
|
||||
NULLIF(prod->'definition'->>'typeProductId', ''),
|
||||
NULLIF(prod->>'selectedProductId', ''),
|
||||
prod->'definition'->>'familyCode',
|
||||
(ordinality - 1)::int,
|
||||
NOW(), NOW()
|
||||
FROM composants c,
|
||||
LATERAL jsonb_array_elements(c.structure::jsonb->'products') WITH ORDINALITY AS t(prod, ordinality)
|
||||
WHERE c.structure IS NOT NULL
|
||||
AND (c.structure::jsonb->'products') IS NOT NULL
|
||||
AND jsonb_array_length(c.structure::jsonb->'products') > 0
|
||||
AND NOT EXISTS (SELECT 1 FROM composant_product_slots cps WHERE cps."composantid" = c.id)
|
||||
AND (NULLIF(prod->'definition'->>'typeProductId', '') IS NULL OR EXISTS (SELECT 1 FROM model_types mt WHERE mt.id = prod->'definition'->>'typeProductId'))
|
||||
AND (NULLIF(prod->>'selectedProductId', '') IS NULL OR EXISTS (SELECT 1 FROM products p WHERE p.id = prod->>'selectedProductId'))
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS composant_product_slots');
|
||||
$this->addSql('DROP TABLE IF EXISTS composant_subcomponent_slots');
|
||||
$this->addSql('DROP TABLE IF EXISTS composant_piece_slots');
|
||||
}
|
||||
}
|
||||
30
migrations/Version20260312200000.php
Normal file
30
migrations/Version20260312200000.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Drop the legacy productIds JSON column from pieces table.
|
||||
* Data has been migrated to the piece_products join table.
|
||||
*/
|
||||
final class Version20260312200000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop legacy productIds JSON column from pieces table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS productids');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE pieces ADD COLUMN productids JSON DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
30
migrations/Version20260312210000.php
Normal file
30
migrations/Version20260312210000.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Drop the legacy structure JSON column from composants table.
|
||||
* Data has been migrated to composant_piece_slots, composant_subcomponent_slots, composant_product_slots tables.
|
||||
*/
|
||||
final class Version20260312210000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Drop legacy structure JSON column from composants table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE composants DROP COLUMN IF EXISTS structure');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE composants ADD COLUMN structure JSON DEFAULT NULL');
|
||||
}
|
||||
}
|
||||
118
migrations/Version20260313124029.php
Normal file
118
migrations/Version20260313124029.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Create piece_product_slots table (mirroring composant_product_slots)
|
||||
* and add version columns to composants, pieces, products.
|
||||
*/
|
||||
final class Version20260313124029 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create piece_product_slots table, add version columns to composants/pieces/products, migrate piece_products data';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// ── Create piece_product_slots table (idempotent) ─────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS piece_product_slots (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"pieceid" VARCHAR(36) NOT NULL,
|
||||
"typeproductid" VARCHAR(36) DEFAULT NULL,
|
||||
"selectedproductid" VARCHAR(36) DEFAULT NULL,
|
||||
"familycode" VARCHAR(255) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
// ── Indexes (idempotent) ──────────────────────────────────────────────
|
||||
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_prod_slot_piece ON piece_product_slots ("pieceid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_prod_slot_type ON piece_product_slots ("typeproductid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_prod_slot_selected ON piece_product_slots ("selectedproductid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_product_slots_piece_pos ON piece_product_slots ("pieceid", position)');
|
||||
|
||||
// ── Foreign keys (idempotent via DO $$ block) ─────────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_piece_prod_slot_piece') THEN
|
||||
ALTER TABLE piece_product_slots
|
||||
ADD CONSTRAINT fk_piece_prod_slot_piece
|
||||
FOREIGN KEY ("pieceid") REFERENCES pieces (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_piece_prod_slot_type') THEN
|
||||
ALTER TABLE piece_product_slots
|
||||
ADD CONSTRAINT fk_piece_prod_slot_type
|
||||
FOREIGN KEY ("typeproductid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_piece_prod_slot_selected') THEN
|
||||
ALTER TABLE piece_product_slots
|
||||
ADD CONSTRAINT fk_piece_prod_slot_selected
|
||||
FOREIGN KEY ("selectedproductid") REFERENCES products (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// ── Add version columns (idempotent) ─────────────────────────────────
|
||||
|
||||
$this->addSql('ALTER TABLE composants ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
|
||||
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
|
||||
$this->addSql('ALTER TABLE products ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
|
||||
|
||||
// ── Data migration: piece_products → piece_product_slots ─────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'piece_products') THEN
|
||||
INSERT INTO piece_product_slots (id, "pieceid", "typeproductid", "selectedproductid", "familycode", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
pp.piece_id,
|
||||
p.typeproductid,
|
||||
pp.product_id,
|
||||
NULL,
|
||||
ROW_NUMBER() OVER (PARTITION BY pp.piece_id ORDER BY pp.product_id) - 1,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM piece_products pp
|
||||
JOIN products p ON p.id = pp.product_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM piece_product_slots pps
|
||||
WHERE pps."pieceid" = pp.piece_id AND pps."selectedproductid" = pp.product_id
|
||||
);
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS piece_product_slots');
|
||||
$this->addSql('ALTER TABLE composants DROP COLUMN IF EXISTS version');
|
||||
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS version');
|
||||
$this->addSql('ALTER TABLE products DROP COLUMN IF EXISTS version');
|
||||
}
|
||||
}
|
||||
59
src/Controller/ComposantPieceSlotController.php
Normal file
59
src/Controller/ComposantPieceSlotController.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\ComposantPieceSlot;
|
||||
use App\Entity\Piece;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/composant-piece-slots')]
|
||||
class ComposantPieceSlotController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}', name: 'composant_piece_slot_patch', methods: ['PATCH'])]
|
||||
public function patch(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$slot = $this->entityManager->find(ComposantPieceSlot::class, $id);
|
||||
if (!$slot) {
|
||||
return $this->json(['success' => false, 'error' => 'Slot not found.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
|
||||
}
|
||||
|
||||
if (array_key_exists('quantity', $payload)) {
|
||||
$slot->setQuantity(max(1, (int) $payload['quantity']));
|
||||
}
|
||||
|
||||
if (array_key_exists('selectedPieceId', $payload)) {
|
||||
if (null === $payload['selectedPieceId']) {
|
||||
$slot->setSelectedPiece(null);
|
||||
} else {
|
||||
$piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']);
|
||||
$slot->setSelectedPiece($piece);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'id' => $slot->getId(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
54
src/Controller/ComposantProductSlotController.php
Normal file
54
src/Controller/ComposantProductSlotController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\ComposantProductSlot;
|
||||
use App\Entity\Product;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/composant-product-slots')]
|
||||
class ComposantProductSlotController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}', name: 'composant_product_slot_patch', methods: ['PATCH'])]
|
||||
public function patch(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$slot = $this->entityManager->find(ComposantProductSlot::class, $id);
|
||||
if (!$slot) {
|
||||
return $this->json(['success' => false, 'error' => 'Slot not found.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
|
||||
}
|
||||
|
||||
if (array_key_exists('selectedProductId', $payload)) {
|
||||
if (null === $payload['selectedProductId']) {
|
||||
$slot->setSelectedProduct(null);
|
||||
} else {
|
||||
$product = $this->entityManager->find(Product::class, $payload['selectedProductId']);
|
||||
$slot->setSelectedProduct($product);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'id' => $slot->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
54
src/Controller/ComposantSubcomponentSlotController.php
Normal file
54
src/Controller/ComposantSubcomponentSlotController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\ComposantSubcomponentSlot;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/composant-subcomponent-slots')]
|
||||
class ComposantSubcomponentSlotController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}', name: 'composant_subcomponent_slot_patch', methods: ['PATCH'])]
|
||||
public function patch(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$slot = $this->entityManager->find(ComposantSubcomponentSlot::class, $id);
|
||||
if (!$slot) {
|
||||
return $this->json(['success' => false, 'error' => 'Slot not found.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
|
||||
}
|
||||
|
||||
if (array_key_exists('selectedComposantId', $payload)) {
|
||||
if (null === $payload['selectedComposantId']) {
|
||||
$slot->setSelectedComposant(null);
|
||||
} else {
|
||||
$composant = $this->entityManager->find(Composant::class, $payload['selectedComposantId']);
|
||||
$slot->setSelectedComposant($composant);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'id' => $slot->getId(),
|
||||
'selectedComposantId' => $slot->getSelectedComposant()?->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -242,6 +242,7 @@ class MachineStructureController extends AbstractController
|
||||
$newLink->setNameOverride($link->getNameOverride());
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
$newLink->setPrixOverride($link->getPrixOverride());
|
||||
$newLink->setQuantity($link->getQuantity());
|
||||
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($componentLinkMap[$parent->getId()])) {
|
||||
@@ -395,6 +396,11 @@ class MachineStructureController extends AbstractController
|
||||
|
||||
$this->applyOverrides($link, $entry['overrides'] ?? null);
|
||||
|
||||
if (!isset($entry['parentComponentLinkId']) && !isset($entry['parentLinkId'])) {
|
||||
$quantity = isset($entry['quantity']) ? (int) $entry['quantity'] : $link->getQuantity();
|
||||
$link->setQuantity(max(1, $quantity));
|
||||
}
|
||||
|
||||
$pendingParents[$linkId] = $this->resolveIdentifier($entry, [
|
||||
'parentComponentLinkId',
|
||||
'parentLinkId',
|
||||
@@ -636,10 +642,31 @@ class MachineStructureController extends AbstractController
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'quantity' => $this->resolvePieceQuantity($link),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
private function resolvePieceQuantity(MachinePieceLink $link): int
|
||||
{
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
if (!$parentLink) {
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
$composant = $parentLink->getComposant();
|
||||
$piece = $link->getPiece();
|
||||
|
||||
foreach ($composant->getPieceSlots() as $slot) {
|
||||
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
|
||||
return $slot->getQuantity();
|
||||
}
|
||||
}
|
||||
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
private function normalizeProductLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineProductLink $link): array {
|
||||
@@ -671,6 +698,7 @@ class MachineStructureController extends AbstractController
|
||||
'typeComposant' => $this->normalizeModelType($type),
|
||||
'productId' => $composant->getProduct()?->getId(),
|
||||
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
|
||||
'structure' => $this->buildStructureFromSlots($composant),
|
||||
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [],
|
||||
@@ -678,6 +706,48 @@ class MachineStructureController extends AbstractController
|
||||
];
|
||||
}
|
||||
|
||||
private function buildStructureFromSlots(Composant $composant): array
|
||||
{
|
||||
$pieces = [];
|
||||
foreach ($composant->getPieceSlots() as $slot) {
|
||||
$pieceData = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
];
|
||||
if ($slot->getSelectedPiece()) {
|
||||
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece());
|
||||
}
|
||||
$pieces[] = $pieceData;
|
||||
}
|
||||
|
||||
$subcomponents = [];
|
||||
foreach ($composant->getSubcomponentSlots() as $slot) {
|
||||
$subcomponents[] = [
|
||||
'alias' => $slot->getAlias(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'typeComposantId' => $slot->getTypeComposant()?->getId(),
|
||||
'selectedComponentId' => $slot->getSelectedComposant()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
$products = [];
|
||||
foreach ($composant->getProductSlots() as $slot) {
|
||||
$products[] = [
|
||||
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'pieces' => $pieces,
|
||||
'subcomponents' => $subcomponents,
|
||||
'products' => $products,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePiece(Piece $piece): array
|
||||
{
|
||||
$type = $piece->getTypePiece();
|
||||
@@ -700,16 +770,19 @@ class MachineStructureController extends AbstractController
|
||||
|
||||
private function normalizeProduct(Product $product): array
|
||||
{
|
||||
$type = $product->getTypeProduct();
|
||||
|
||||
return [
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'supplierPrice' => $product->getSupplierPrice(),
|
||||
'typeProductId' => $product->getTypeProduct()?->getId(),
|
||||
'typeProduct' => $this->normalizeModelType($product->getTypeProduct()),
|
||||
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => [],
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'supplierPrice' => $product->getSupplierPrice(),
|
||||
'typeProductId' => $type?->getId(),
|
||||
'typeProduct' => $this->normalizeModelType($type),
|
||||
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
|
||||
'documents' => [],
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getProductCustomFields()) : [],
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($product->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
74
src/Controller/ModelTypeSyncController.php
Normal file
74
src/Controller/ModelTypeSyncController.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use App\Service\ModelTypeSyncService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/model_types/{id}')]
|
||||
final class ModelTypeSyncController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
private readonly ModelTypeSyncService $syncService,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/sync-preview', name: 'api_model_type_sync_preview', methods: ['POST'])]
|
||||
public function preview(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$modelType = $this->modelTypes->find($id);
|
||||
|
||||
if (!$modelType) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Catégorie introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$structure = $body['structure'] ?? [];
|
||||
|
||||
$result = $this->syncService->preview($modelType, $structure);
|
||||
|
||||
return new JsonResponse($result);
|
||||
}
|
||||
|
||||
#[Route('/sync', name: 'api_model_type_sync', methods: ['POST'])]
|
||||
public function sync(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$modelType = $this->modelTypes->find($id);
|
||||
|
||||
if (!$modelType) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Catégorie introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$confirmation = new SyncConfirmation(
|
||||
confirmDeletions: $body['confirmDeletions'] ?? false,
|
||||
confirmTypeChanges: $body['confirmTypeChanges'] ?? false,
|
||||
);
|
||||
|
||||
$result = $this->em->wrapInTransaction(function () use ($modelType, $confirmation) {
|
||||
return $this->syncService->execute($modelType, $confirmation);
|
||||
});
|
||||
|
||||
return new JsonResponse($result);
|
||||
}
|
||||
}
|
||||
13
src/DTO/SyncConfirmation.php
Normal file
13
src/DTO/SyncConfirmation.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
class SyncConfirmation
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $confirmDeletions = false,
|
||||
public readonly bool $confirmTypeChanges = false,
|
||||
) {}
|
||||
}
|
||||
27
src/DTO/SyncExecutionResult.php
Normal file
27
src/DTO/SyncExecutionResult.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
class SyncExecutionResult implements JsonSerializable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $itemsUpdated,
|
||||
public readonly array $additions = [],
|
||||
public readonly array $deletions = [],
|
||||
public readonly array $modifications = [],
|
||||
) {}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'itemsUpdated' => $this->itemsUpdated,
|
||||
'additions' => $this->additions,
|
||||
'deletions' => $this->deletions,
|
||||
'modifications' => $this->modifications,
|
||||
];
|
||||
}
|
||||
}
|
||||
38
src/DTO/SyncPreviewResult.php
Normal file
38
src/DTO/SyncPreviewResult.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
class SyncPreviewResult implements JsonSerializable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $modelTypeId,
|
||||
public readonly string $category,
|
||||
public readonly int $itemCount,
|
||||
public readonly array $additions = [],
|
||||
public readonly array $deletions = [],
|
||||
public readonly array $modifications = [],
|
||||
) {}
|
||||
|
||||
public function hasImpact(): bool
|
||||
{
|
||||
return array_sum($this->additions) > 0
|
||||
|| array_sum($this->deletions) > 0
|
||||
|| array_sum($this->modifications) > 0;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'modelTypeId' => $this->modelTypeId,
|
||||
'category' => $this->category,
|
||||
'itemCount' => $this->itemCount,
|
||||
'additions' => $this->additions,
|
||||
'deletions' => $this->deletions,
|
||||
'modifications' => $this->modifications,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -67,10 +67,6 @@ class Composant
|
||||
#[Groups(['composant:read'])]
|
||||
private ?string $prix = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
private ?array $structure = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'composants')]
|
||||
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true)]
|
||||
#[Groups(['composant:read'])]
|
||||
@@ -113,6 +109,31 @@ class Composant
|
||||
#[ORM\OneToMany(mappedBy: 'composant', targetEntity: MachineComponentLink::class)]
|
||||
private Collection $machineLinks;
|
||||
|
||||
/**
|
||||
* @var Collection<int, ComposantPieceSlot>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: ComposantPieceSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $pieceSlots;
|
||||
|
||||
/**
|
||||
* @var Collection<int, ComposantSubcomponentSlot>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: ComposantSubcomponentSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $subcomponentSlots;
|
||||
|
||||
/**
|
||||
* @var Collection<int, ComposantProductSlot>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: ComposantProductSlot::class, mappedBy: 'composant', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $productSlots;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Groups(['composant:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['composant:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -129,6 +150,9 @@ class Composant
|
||||
$this->documents = new ArrayCollection();
|
||||
$this->customFieldValues = new ArrayCollection();
|
||||
$this->machineLinks = new ArrayCollection();
|
||||
$this->pieceSlots = new ArrayCollection();
|
||||
$this->subcomponentSlots = new ArrayCollection();
|
||||
$this->productSlots = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
@@ -179,18 +203,6 @@ class Composant
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStructure(): ?array
|
||||
{
|
||||
return $this->structure;
|
||||
}
|
||||
|
||||
public function setStructure(?array $structure): static
|
||||
{
|
||||
$this->structure = $structure;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeComposant(): ?ModelType
|
||||
{
|
||||
return $this->typeComposant;
|
||||
@@ -270,4 +282,144 @@ class Composant
|
||||
{
|
||||
return $this->customFieldValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ComposantPieceSlot>
|
||||
*/
|
||||
public function getPieceSlots(): Collection
|
||||
{
|
||||
return $this->pieceSlots;
|
||||
}
|
||||
|
||||
public function addPieceSlot(ComposantPieceSlot $pieceSlot): static
|
||||
{
|
||||
if (!$this->pieceSlots->contains($pieceSlot)) {
|
||||
$this->pieceSlots->add($pieceSlot);
|
||||
$pieceSlot->setComposant($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removePieceSlot(ComposantPieceSlot $pieceSlot): static
|
||||
{
|
||||
$this->pieceSlots->removeElement($pieceSlot);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ComposantSubcomponentSlot>
|
||||
*/
|
||||
public function getSubcomponentSlots(): Collection
|
||||
{
|
||||
return $this->subcomponentSlots;
|
||||
}
|
||||
|
||||
public function addSubcomponentSlot(ComposantSubcomponentSlot $subcomponentSlot): static
|
||||
{
|
||||
if (!$this->subcomponentSlots->contains($subcomponentSlot)) {
|
||||
$this->subcomponentSlots->add($subcomponentSlot);
|
||||
$subcomponentSlot->setComposant($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSubcomponentSlot(ComposantSubcomponentSlot $subcomponentSlot): static
|
||||
{
|
||||
$this->subcomponentSlots->removeElement($subcomponentSlot);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ComposantProductSlot>
|
||||
*/
|
||||
public function getProductSlots(): Collection
|
||||
{
|
||||
return $this->productSlots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual property — rebuilds the legacy structure JSON from slot tables.
|
||||
*
|
||||
* @return null|array{pieces: list<array<string, mixed>>, products: list<array<string, mixed>>, subcomponents: list<array<string, mixed>>}
|
||||
*/
|
||||
#[Groups(['composant:read'])]
|
||||
public function getStructure(): ?array
|
||||
{
|
||||
$pieces = [];
|
||||
foreach ($this->pieceSlots as $slot) {
|
||||
$pieces[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$products = [];
|
||||
foreach ($this->productSlots as $slot) {
|
||||
$products[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$subcomponents = [];
|
||||
foreach ($this->subcomponentSlots as $slot) {
|
||||
$subcomponents[] = [
|
||||
'slotId' => $slot->getId(),
|
||||
'alias' => $slot->getAlias(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'typeComposantId' => $slot->getTypeComposant()?->getId(),
|
||||
'selectedComponentId' => $slot->getSelectedComposant()?->getId(),
|
||||
'position' => $slot->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($pieces) && empty($products) && empty($subcomponents)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'pieces' => $pieces,
|
||||
'products' => $products,
|
||||
'subcomponents' => $subcomponents,
|
||||
];
|
||||
}
|
||||
|
||||
public function addProductSlot(ComposantProductSlot $productSlot): static
|
||||
{
|
||||
if (!$this->productSlots->contains($productSlot)) {
|
||||
$this->productSlots->add($productSlot);
|
||||
$productSlot->setComposant($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeProductSlot(ComposantProductSlot $productSlot): static
|
||||
{
|
||||
$this->productSlots->removeElement($productSlot);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function incrementVersion(): static
|
||||
{
|
||||
++$this->version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
112
src/Entity/ComposantPieceSlot.php
Normal file
112
src/Entity/ComposantPieceSlot.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'composant_piece_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class ComposantPieceSlot
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'pieceSlots')]
|
||||
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Composant $composant;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typePieceId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typePiece = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Piece::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedPieceId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Piece $selectedPiece = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
private int $quantity = 1;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getComposant(): Composant
|
||||
{
|
||||
return $this->composant;
|
||||
}
|
||||
|
||||
public function setComposant(Composant $composant): static
|
||||
{
|
||||
$this->composant = $composant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypePiece(): ?ModelType
|
||||
{
|
||||
return $this->typePiece;
|
||||
}
|
||||
|
||||
public function setTypePiece(?ModelType $typePiece): static
|
||||
{
|
||||
$this->typePiece = $typePiece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSelectedPiece(): ?Piece
|
||||
{
|
||||
return $this->selectedPiece;
|
||||
}
|
||||
|
||||
public function setSelectedPiece(?Piece $selectedPiece): static
|
||||
{
|
||||
$this->selectedPiece = $selectedPiece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getQuantity(): int
|
||||
{
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function setQuantity(int $quantity): static
|
||||
{
|
||||
$this->quantity = $quantity;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
112
src/Entity/ComposantProductSlot.php
Normal file
112
src/Entity/ComposantProductSlot.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'composant_product_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class ComposantProductSlot
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'productSlots')]
|
||||
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Composant $composant;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeProduct = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Product::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Product $selectedProduct = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getComposant(): Composant
|
||||
{
|
||||
return $this->composant;
|
||||
}
|
||||
|
||||
public function setComposant(Composant $composant): static
|
||||
{
|
||||
$this->composant = $composant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ?ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(?ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSelectedProduct(): ?Product
|
||||
{
|
||||
return $this->selectedProduct;
|
||||
}
|
||||
|
||||
public function setSelectedProduct(?Product $selectedProduct): static
|
||||
{
|
||||
$this->selectedProduct = $selectedProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): ?string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(?string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
127
src/Entity/ComposantSubcomponentSlot.php
Normal file
127
src/Entity/ComposantSubcomponentSlot.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'composant_subcomponent_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class ComposantSubcomponentSlot
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'subcomponentSlots')]
|
||||
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Composant $composant;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
|
||||
private ?string $alias = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeComposant = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Composant::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Composant $selectedComposant = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getComposant(): Composant
|
||||
{
|
||||
return $this->composant;
|
||||
}
|
||||
|
||||
public function setComposant(Composant $composant): static
|
||||
{
|
||||
$this->composant = $composant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAlias(): ?string
|
||||
{
|
||||
return $this->alias;
|
||||
}
|
||||
|
||||
public function setAlias(?string $alias): static
|
||||
{
|
||||
$this->alias = $alias;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): ?string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(?string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeComposant(): ?ModelType
|
||||
{
|
||||
return $this->typeComposant;
|
||||
}
|
||||
|
||||
public function setTypeComposant(?ModelType $typeComposant): static
|
||||
{
|
||||
$this->typeComposant = $typeComposant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSelectedComposant(): ?Composant
|
||||
{
|
||||
return $this->selectedComposant;
|
||||
}
|
||||
|
||||
public function setSelectedComposant(?Composant $selectedComposant): static
|
||||
{
|
||||
$this->selectedComposant = $selectedComposant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -184,4 +184,40 @@ class CustomField
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeComposant(): ?ModelType
|
||||
{
|
||||
return $this->typeComposant;
|
||||
}
|
||||
|
||||
public function setTypeComposant(?ModelType $typeComposant): static
|
||||
{
|
||||
$this->typeComposant = $typeComposant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypePiece(): ?ModelType
|
||||
{
|
||||
return $this->typePiece;
|
||||
}
|
||||
|
||||
public function setTypePiece(?ModelType $typePiece): static
|
||||
{
|
||||
$this->typePiece = $typePiece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ?ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(?ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: MachinePieceLinkRepository::class)]
|
||||
#[ORM\Table(name: 'machine_piece_links')]
|
||||
@@ -68,6 +69,10 @@ class MachinePieceLink
|
||||
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 2, nullable: true, name: 'prixOverride')]
|
||||
private ?string $prixOverride = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Assert\GreaterThanOrEqual(1)]
|
||||
private int $quantity = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
@@ -152,4 +157,16 @@ class MachinePieceLink
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getQuantity(): int
|
||||
{
|
||||
return $this->quantity;
|
||||
}
|
||||
|
||||
public function setQuantity(int $quantity): static
|
||||
{
|
||||
$this->quantity = $quantity;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use ApiPlatform\Metadata\Put;
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use App\State\ModelTypeProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
@@ -35,9 +36,9 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
operations: [
|
||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ModelTypeProcessor::class),
|
||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ModelTypeProcessor::class),
|
||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ModelTypeProcessor::class),
|
||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||
],
|
||||
paginationClientItemsPerPage: true,
|
||||
@@ -72,18 +73,6 @@ class ModelType
|
||||
#[Groups(['type_machine:read', 'model_type:read', 'model_type:write'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'componentSkeleton')]
|
||||
#[Groups(['model_type:read', 'composant:read'])]
|
||||
private ?array $componentSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'pieceSkeleton')]
|
||||
#[Groups(['model_type:read', 'piece:read'])]
|
||||
private ?array $pieceSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'productSkeleton')]
|
||||
#[Groups(['model_type:read', 'product:read'])]
|
||||
private ?array $productSkeleton = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['model_type:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -130,16 +119,40 @@ class ModelType
|
||||
#[ORM\OneToMany(mappedBy: 'typeProduct', targetEntity: CustomField::class)]
|
||||
private Collection $productCustomFields;
|
||||
|
||||
/**
|
||||
* @var Collection<int, SkeletonPieceRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: SkeletonPieceRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $skeletonPieceRequirements;
|
||||
|
||||
/**
|
||||
* @var Collection<int, SkeletonProductRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: SkeletonProductRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $skeletonProductRequirements;
|
||||
|
||||
/**
|
||||
* @var Collection<int, SkeletonSubcomponentRequirement>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: SkeletonSubcomponentRequirement::class, mappedBy: 'modelType', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $skeletonSubcomponentRequirements;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->composants = new ArrayCollection();
|
||||
$this->pieces = new ArrayCollection();
|
||||
$this->products = new ArrayCollection();
|
||||
$this->customFields = new ArrayCollection();
|
||||
$this->pieceCustomFields = new ArrayCollection();
|
||||
$this->productCustomFields = new ArrayCollection();
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->composants = new ArrayCollection();
|
||||
$this->pieces = new ArrayCollection();
|
||||
$this->products = new ArrayCollection();
|
||||
$this->customFields = new ArrayCollection();
|
||||
$this->pieceCustomFields = new ArrayCollection();
|
||||
$this->productCustomFields = new ArrayCollection();
|
||||
$this->skeletonPieceRequirements = new ArrayCollection();
|
||||
$this->skeletonProductRequirements = new ArrayCollection();
|
||||
$this->skeletonSubcomponentRequirements = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
@@ -175,11 +188,6 @@ class ModelType
|
||||
{
|
||||
$this->category = $category;
|
||||
|
||||
if (null !== $this->pendingStructure) {
|
||||
$this->applyStructureForCategory($this->pendingStructure, $category);
|
||||
$this->pendingStructure = null;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -207,66 +215,34 @@ class ModelType
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComponentSkeleton(): ?array
|
||||
{
|
||||
return $this->componentSkeleton;
|
||||
}
|
||||
|
||||
public function setComponentSkeleton(?array $componentSkeleton): static
|
||||
{
|
||||
$this->componentSkeleton = $componentSkeleton;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPieceSkeleton(): ?array
|
||||
{
|
||||
return $this->pieceSkeleton;
|
||||
}
|
||||
|
||||
public function setPieceSkeleton(?array $pieceSkeleton): static
|
||||
{
|
||||
$this->pieceSkeleton = $pieceSkeleton;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProductSkeleton(): ?array
|
||||
{
|
||||
return $this->productSkeleton;
|
||||
}
|
||||
|
||||
public function setProductSkeleton(?array $productSkeleton): static
|
||||
{
|
||||
$this->productSkeleton = $productSkeleton;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['model_type:read', 'product:read', 'composant:read', 'piece:read'])]
|
||||
public function getStructure(): ?array
|
||||
{
|
||||
return match ($this->category) {
|
||||
ModelCategory::COMPONENT => $this->componentSkeleton,
|
||||
ModelCategory::PIECE => $this->pieceSkeleton,
|
||||
ModelCategory::PRODUCT => $this->productSkeleton,
|
||||
ModelCategory::COMPONENT => $this->getComponentStructureFromRelations(),
|
||||
ModelCategory::PIECE => $this->getPieceStructureFromRelations(),
|
||||
ModelCategory::PRODUCT => ['customFields' => $this->serializeCustomFields($this->productCustomFields)],
|
||||
};
|
||||
}
|
||||
|
||||
#[Groups(['model_type:write'])]
|
||||
public function setStructure(?array $structure): static
|
||||
{
|
||||
if (!isset($this->category)) {
|
||||
$this->pendingStructure = $structure;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$this->applyStructureForCategory($structure, $this->category);
|
||||
$this->pendingStructure = $structure;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPendingStructure(): ?array
|
||||
{
|
||||
return $this->pendingStructure;
|
||||
}
|
||||
|
||||
public function clearPendingStructure(): void
|
||||
{
|
||||
$this->pendingStructure = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, CustomField>
|
||||
*/
|
||||
@@ -291,26 +267,140 @@ class ModelType
|
||||
return $this->productCustomFields;
|
||||
}
|
||||
|
||||
private function applyStructureForCategory(?array $structure, ModelCategory $category): void
|
||||
/**
|
||||
* @return Collection<int, SkeletonPieceRequirement>
|
||||
*/
|
||||
public function getSkeletonPieceRequirements(): Collection
|
||||
{
|
||||
if (ModelCategory::COMPONENT === $category) {
|
||||
$this->componentSkeleton = $structure;
|
||||
$this->pieceSkeleton = null;
|
||||
$this->productSkeleton = null;
|
||||
return $this->skeletonPieceRequirements;
|
||||
}
|
||||
|
||||
return;
|
||||
public function addSkeletonPieceRequirement(SkeletonPieceRequirement $requirement): static
|
||||
{
|
||||
if (!$this->skeletonPieceRequirements->contains($requirement)) {
|
||||
$this->skeletonPieceRequirements->add($requirement);
|
||||
$requirement->setModelType($this);
|
||||
}
|
||||
|
||||
if (ModelCategory::PIECE === $category) {
|
||||
$this->pieceSkeleton = $structure;
|
||||
$this->componentSkeleton = null;
|
||||
$this->productSkeleton = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
return;
|
||||
public function removeSkeletonPieceRequirement(SkeletonPieceRequirement $requirement): static
|
||||
{
|
||||
$this->skeletonPieceRequirements->removeElement($requirement);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SkeletonProductRequirement>
|
||||
*/
|
||||
public function getSkeletonProductRequirements(): Collection
|
||||
{
|
||||
return $this->skeletonProductRequirements;
|
||||
}
|
||||
|
||||
public function addSkeletonProductRequirement(SkeletonProductRequirement $requirement): static
|
||||
{
|
||||
if (!$this->skeletonProductRequirements->contains($requirement)) {
|
||||
$this->skeletonProductRequirements->add($requirement);
|
||||
$requirement->setModelType($this);
|
||||
}
|
||||
|
||||
$this->productSkeleton = $structure;
|
||||
$this->componentSkeleton = null;
|
||||
$this->pieceSkeleton = null;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSkeletonProductRequirement(SkeletonProductRequirement $requirement): static
|
||||
{
|
||||
$this->skeletonProductRequirements->removeElement($requirement);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, SkeletonSubcomponentRequirement>
|
||||
*/
|
||||
public function getSkeletonSubcomponentRequirements(): Collection
|
||||
{
|
||||
return $this->skeletonSubcomponentRequirements;
|
||||
}
|
||||
|
||||
public function addSkeletonSubcomponentRequirement(SkeletonSubcomponentRequirement $requirement): static
|
||||
{
|
||||
if (!$this->skeletonSubcomponentRequirements->contains($requirement)) {
|
||||
$this->skeletonSubcomponentRequirements->add($requirement);
|
||||
$requirement->setModelType($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeSkeletonSubcomponentRequirement(SkeletonSubcomponentRequirement $requirement): static
|
||||
{
|
||||
$this->skeletonSubcomponentRequirements->removeElement($requirement);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function getComponentStructureFromRelations(): array
|
||||
{
|
||||
$structure = ['customFields' => $this->serializeCustomFields($this->customFields), 'pieces' => [], 'products' => [], 'subcomponents' => []];
|
||||
|
||||
foreach ($this->skeletonPieceRequirements as $req) {
|
||||
$structure['pieces'][] = [
|
||||
'typePieceId' => $req->getTypePiece()->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->skeletonProductRequirements as $req) {
|
||||
$structure['products'][] = [
|
||||
'typeProductId' => $req->getTypeProduct()->getId(),
|
||||
'familyCode' => $req->getFamilyCode(),
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($this->skeletonSubcomponentRequirements as $req) {
|
||||
$structure['subcomponents'][] = [
|
||||
'alias' => $req->getAlias(),
|
||||
'familyCode' => $req->getFamilyCode(),
|
||||
'typeComposantId' => $req->getTypeComposant()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
return $structure;
|
||||
}
|
||||
|
||||
private function getPieceStructureFromRelations(): array
|
||||
{
|
||||
return [
|
||||
'customFields' => $this->serializeCustomFields($this->pieceCustomFields),
|
||||
'products' => array_map(fn (SkeletonProductRequirement $req) => [
|
||||
'typeProductId' => $req->getTypeProduct()->getId(),
|
||||
'familyCode' => $req->getFamilyCode(),
|
||||
], $this->skeletonProductRequirements->toArray()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, CustomField> $fields
|
||||
*/
|
||||
private function serializeCustomFields(Collection $fields): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($fields as $cf) {
|
||||
$items[] = [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
|
||||
|
||||
return $items;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,10 +79,6 @@ class Piece
|
||||
#[Groups(['piece:read'])]
|
||||
private ?Product $product = null;
|
||||
|
||||
#[ORM\Column(type: Types::JSON, nullable: true, name: 'productIds')]
|
||||
#[Groups(['piece:read'])]
|
||||
private ?array $productIds = null;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Constructeur>
|
||||
*/
|
||||
@@ -109,12 +105,32 @@ class Piece
|
||||
#[Groups(['piece:read'])]
|
||||
private Collection $customFieldValues;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Product>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Product::class, inversedBy: 'linkedPieces')]
|
||||
#[ORM\JoinTable(name: 'piece_products')]
|
||||
#[ORM\JoinColumn(name: 'piece_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
#[ORM\InverseJoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
private Collection $products;
|
||||
|
||||
/**
|
||||
* @var Collection<int, PieceProductSlot>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: PieceProductSlot::class, mappedBy: 'piece', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $productSlots;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachinePieceLink>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'piece', targetEntity: MachinePieceLink::class)]
|
||||
private Collection $machineLinks;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Groups(['piece:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['piece:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -130,6 +146,8 @@ class Piece
|
||||
$this->constructeurs = new ArrayCollection();
|
||||
$this->documents = new ArrayCollection();
|
||||
$this->customFieldValues = new ArrayCollection();
|
||||
$this->products = new ArrayCollection();
|
||||
$this->productSlots = new ArrayCollection();
|
||||
$this->machineLinks = new ArrayCollection();
|
||||
}
|
||||
|
||||
@@ -202,13 +220,8 @@ class Piece
|
||||
{
|
||||
$this->product = $product;
|
||||
|
||||
if ($product && empty($this->productIds)) {
|
||||
$productId = $product->getId();
|
||||
$this->productIds = $productId ? [$productId] : null;
|
||||
}
|
||||
|
||||
if (!$product && empty($this->productIds)) {
|
||||
$this->productIds = null;
|
||||
if (null !== $product) {
|
||||
$this->addProduct($product);
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -217,46 +230,10 @@ class Piece
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
#[Groups(['piece:read'])]
|
||||
public function getProductIds(): array
|
||||
{
|
||||
if (!is_array($this->productIds)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(
|
||||
array_filter(
|
||||
array_map(
|
||||
static fn ($value) => is_string($value) ? trim($value) : '',
|
||||
$this->productIds,
|
||||
),
|
||||
static fn (string $value) => '' !== $value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
public function setProductIds(?array $productIds): static
|
||||
{
|
||||
if (!is_array($productIds)) {
|
||||
$this->productIds = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
$normalized = array_values(
|
||||
array_unique(
|
||||
array_filter(
|
||||
array_map(
|
||||
static fn ($value) => is_string($value) ? trim($value) : '',
|
||||
$productIds,
|
||||
),
|
||||
static fn (string $value) => '' !== $value,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
$this->productIds = [] === $normalized ? null : $normalized;
|
||||
|
||||
return $this;
|
||||
return $this->products->map(fn (Product $p) => $p->getId())->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -298,4 +275,65 @@ class Piece
|
||||
{
|
||||
return $this->customFieldValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Product>
|
||||
*/
|
||||
public function getProducts(): Collection
|
||||
{
|
||||
return $this->products;
|
||||
}
|
||||
|
||||
public function addProduct(Product $product): static
|
||||
{
|
||||
if (!$this->products->contains($product)) {
|
||||
$this->products->add($product);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeProduct(Product $product): static
|
||||
{
|
||||
$this->products->removeElement($product);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, PieceProductSlot>
|
||||
*/
|
||||
public function getProductSlots(): Collection
|
||||
{
|
||||
return $this->productSlots;
|
||||
}
|
||||
|
||||
public function addProductSlot(PieceProductSlot $slot): static
|
||||
{
|
||||
if (!$this->productSlots->contains($slot)) {
|
||||
$this->productSlots->add($slot);
|
||||
$slot->setPiece($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeProductSlot(PieceProductSlot $slot): static
|
||||
{
|
||||
$this->productSlots->removeElement($slot);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function incrementVersion(): static
|
||||
{
|
||||
++$this->version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
112
src/Entity/PieceProductSlot.php
Normal file
112
src/Entity/PieceProductSlot.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'piece_product_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class PieceProductSlot
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'productSlots')]
|
||||
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Piece $piece;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeProduct = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Product::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Product $selectedProduct = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getPiece(): Piece
|
||||
{
|
||||
return $this->piece;
|
||||
}
|
||||
|
||||
public function setPiece(Piece $piece): static
|
||||
{
|
||||
$this->piece = $piece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ?ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(?ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSelectedProduct(): ?Product
|
||||
{
|
||||
return $this->selectedProduct;
|
||||
}
|
||||
|
||||
public function setSelectedProduct(?Product $selectedProduct): static
|
||||
{
|
||||
$this->selectedProduct = $selectedProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): ?string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(?string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -106,12 +106,22 @@ class Product
|
||||
#[ORM\OneToMany(mappedBy: 'product', targetEntity: Composant::class)]
|
||||
private Collection $composants;
|
||||
|
||||
/**
|
||||
* @var Collection<int, Piece>
|
||||
*/
|
||||
#[ORM\ManyToMany(targetEntity: Piece::class, mappedBy: 'products')]
|
||||
private Collection $linkedPieces;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachineProductLink>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'product', targetEntity: MachineProductLink::class)]
|
||||
private Collection $machineLinks;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Groups(['product:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['product:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -129,6 +139,7 @@ class Product
|
||||
$this->customFieldValues = new ArrayCollection();
|
||||
$this->pieces = new ArrayCollection();
|
||||
$this->composants = new ArrayCollection();
|
||||
$this->linkedPieces = new ArrayCollection();
|
||||
$this->machineLinks = new ArrayCollection();
|
||||
}
|
||||
|
||||
@@ -219,4 +230,24 @@ class Product
|
||||
{
|
||||
return $this->customFieldValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Piece>
|
||||
*/
|
||||
public function getLinkedPieces(): Collection
|
||||
{
|
||||
return $this->linkedPieces;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function incrementVersion(): static
|
||||
{
|
||||
++$this->version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
81
src/Entity/SkeletonPieceRequirement.php
Normal file
81
src/Entity/SkeletonPieceRequirement.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'skeleton_piece_requirements')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class SkeletonPieceRequirement
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'skeletonPieceRequirements')]
|
||||
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $modelType;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typePieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $typePiece;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getModelType(): ModelType
|
||||
{
|
||||
return $this->modelType;
|
||||
}
|
||||
|
||||
public function setModelType(ModelType $modelType): static
|
||||
{
|
||||
$this->modelType = $modelType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypePiece(): ModelType
|
||||
{
|
||||
return $this->typePiece;
|
||||
}
|
||||
|
||||
public function setTypePiece(ModelType $typePiece): static
|
||||
{
|
||||
$this->typePiece = $typePiece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
96
src/Entity/SkeletonProductRequirement.php
Normal file
96
src/Entity/SkeletonProductRequirement.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'skeleton_product_requirements')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class SkeletonProductRequirement
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'skeletonProductRequirements')]
|
||||
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $modelType;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $typeProduct;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getModelType(): ModelType
|
||||
{
|
||||
return $this->modelType;
|
||||
}
|
||||
|
||||
public function setModelType(ModelType $modelType): static
|
||||
{
|
||||
$this->modelType = $modelType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): ?string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(?string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
111
src/Entity/SkeletonSubcomponentRequirement.php
Normal file
111
src/Entity/SkeletonSubcomponentRequirement.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'skeleton_subcomponent_requirements')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class SkeletonSubcomponentRequirement
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class, inversedBy: 'skeletonSubcomponentRequirements')]
|
||||
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private ModelType $modelType;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
private string $alias;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, name: 'familyCode')]
|
||||
private string $familyCode;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeComposantId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeComposant = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getModelType(): ModelType
|
||||
{
|
||||
return $this->modelType;
|
||||
}
|
||||
|
||||
public function setModelType(ModelType $modelType): static
|
||||
{
|
||||
$this->modelType = $modelType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAlias(): string
|
||||
{
|
||||
return $this->alias;
|
||||
}
|
||||
|
||||
public function setAlias(string $alias): static
|
||||
{
|
||||
$this->alias = $alias;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeComposant(): ?ModelType
|
||||
{
|
||||
return $this->typeComposant;
|
||||
}
|
||||
|
||||
public function setTypeComposant(?ModelType $typeComposant): static
|
||||
{
|
||||
$this->typeComposant = $typeComposant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,6 @@ final class ComposantAuditSubscriber extends AbstractAuditSubscriber
|
||||
'name' => $this->safeGet($entity, 'getName'),
|
||||
'reference' => $this->safeGet($entity, 'getReference'),
|
||||
'prix' => $this->safeGet($entity, 'getPrix'),
|
||||
'structure' => $this->safeGet($entity, 'getStructure'),
|
||||
'typeComposant' => $this->normalizeValue($this->safeGet($entity, 'getTypeComposant')),
|
||||
'product' => $this->normalizeValue($this->safeGet($entity, 'getProduct')),
|
||||
'constructeurIds' => $this->normalizeCollection($entity->getConstructeurs()),
|
||||
|
||||
@@ -12,7 +12,7 @@ use Doctrine\ORM\Event\PreUpdateEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
|
||||
/**
|
||||
* Keep the legacy single product relation in sync with the new productIds array.
|
||||
* Keep the legacy single product relation in sync with the ManyToMany products collection.
|
||||
*/
|
||||
final class PieceProductSyncSubscriber implements EventSubscriber
|
||||
{
|
||||
|
||||
35
src/Mcp/Resource/RolesResource.php
Normal file
35
src/Mcp/Resource/RolesResource.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Resource;
|
||||
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
|
||||
#[McpResource(
|
||||
uri: 'inventory://roles',
|
||||
name: 'Roles & Permissions',
|
||||
description: 'Role hierarchy and permissions for MCP tools.',
|
||||
mimeType: 'application/json'
|
||||
)]
|
||||
class RolesResource
|
||||
{
|
||||
public function __invoke(): array
|
||||
{
|
||||
$roles = [
|
||||
'hierarchy' => [
|
||||
'ROLE_ADMIN' => 'Inherits ROLE_GESTIONNAIRE. Can manage profiles.',
|
||||
'ROLE_GESTIONNAIRE' => 'Inherits ROLE_VIEWER. Can create, update, delete all entities.',
|
||||
'ROLE_VIEWER' => 'Inherits ROLE_USER. Can read all entities, create comments, search.',
|
||||
'ROLE_USER' => 'Base role. Authenticated but minimal access.',
|
||||
],
|
||||
'tool_permissions' => [
|
||||
'ROLE_VIEWER' => 'list_*, get_*, search_inventory, get_dashboard_stats, get_entity_history, get_activity_log, list_comments, create_comment, get_unresolved_comments_count, list_custom_field_values, list_documents, list_slots',
|
||||
'ROLE_GESTIONNAIRE' => 'All VIEWER tools + create_*, update_*, delete_*, clone_machine, update_slots, add_machine_links, remove_machine_link, resolve_comment, upsert_custom_field_values, sync_model_type',
|
||||
],
|
||||
];
|
||||
|
||||
return [new TextContent(text: json_encode($roles, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))];
|
||||
}
|
||||
}
|
||||
53
src/Mcp/Resource/SchemaResource.php
Normal file
53
src/Mcp/Resource/SchemaResource.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Resource;
|
||||
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
|
||||
#[McpResource(
|
||||
uri: 'inventory://schema/entities',
|
||||
name: 'Entity Schema',
|
||||
description: 'Complete schema of all inventory entities with their fields, types, and relationships.',
|
||||
mimeType: 'application/json'
|
||||
)]
|
||||
class SchemaResource
|
||||
{
|
||||
public function __invoke(): array
|
||||
{
|
||||
$schema = [
|
||||
'Machine' => [
|
||||
'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'prix (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['site (Site, required)', 'constructeurs (Constructeur[])', 'componentLinks (MachineComponentLink[])', 'pieceLinks (MachinePieceLink[])', 'productLinks (MachineProductLink[])', 'customFields (CustomField[])', 'customFieldValues (CustomFieldValue[])'],
|
||||
],
|
||||
'Composant' => [
|
||||
'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'description (text?)', 'prix (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['typeComposant (ModelType?)', 'constructeurs (Constructeur[])', 'pieceSlots (ComposantPieceSlot[])', 'productSlots (ComposantProductSlot[])', 'subcomponentSlots (ComposantSubcomponentSlot[])', 'customFieldValues (CustomFieldValue[])'],
|
||||
],
|
||||
'Piece' => [
|
||||
'fields' => ['id (string)', 'name (string)', 'reference (string?, unique)', 'description (text?)', 'prix (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['typePiece (ModelType?)', 'product (Product?)', 'constructeurs (Constructeur[])', 'productSlots (PieceProductSlot[])', 'customFieldValues (CustomFieldValue[])'],
|
||||
],
|
||||
'Product' => [
|
||||
'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'supplierPrice (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['typeProduct (ModelType?)', 'constructeurs (Constructeur[])'],
|
||||
],
|
||||
'Site' => [
|
||||
'fields' => ['id (string)', 'name (string)', 'contactName (string)', 'contactPhone (string)', 'contactAddress (string)', 'contactPostalCode (string)', 'contactCity (string)', 'color (string)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['machines (Machine[])'],
|
||||
],
|
||||
'Constructeur' => [
|
||||
'fields' => ['id (string)', 'name (string, unique)', 'email (string?)', 'phone (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['machines (Machine[])', 'composants (Composant[])', 'pieces (Piece[])', 'products (Product[])'],
|
||||
],
|
||||
'ModelType' => [
|
||||
'fields' => ['id (string)', 'name (string)', 'category (machine|composant|piece|product)', 'code (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['skeletonPieceRequirements[]', 'skeletonProductRequirements[]', 'skeletonSubcomponentRequirements[]'],
|
||||
],
|
||||
];
|
||||
|
||||
return [new TextContent(text: json_encode($schema, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))];
|
||||
}
|
||||
}
|
||||
48
src/Mcp/Resource/StatsResource.php
Normal file
48
src/Mcp/Resource/StatsResource.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Resource;
|
||||
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\SiteRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
|
||||
#[McpResource(
|
||||
uri: 'inventory://stats',
|
||||
name: 'Inventory Statistics',
|
||||
description: 'Global counters: machines, pieces, composants, products, sites, unresolved comments.',
|
||||
mimeType: 'application/json'
|
||||
)]
|
||||
class StatsResource
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly PieceRepository $pieces,
|
||||
private readonly ComposantRepository $composants,
|
||||
private readonly ProductRepository $products,
|
||||
private readonly SiteRepository $sites,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function __invoke(): array
|
||||
{
|
||||
$unresolvedComments = (int) $this->em->createQuery(
|
||||
"SELECT COUNT(c.id) FROM App\\Entity\\Comment c WHERE c.status = 'open'"
|
||||
)->getSingleScalarResult();
|
||||
|
||||
return [new TextContent(text: json_encode([
|
||||
'machines' => $this->machines->count([]),
|
||||
'pieces' => $this->pieces->count([]),
|
||||
'composants' => $this->composants->count([]),
|
||||
'products' => $this->products->count([]),
|
||||
'sites' => $this->sites->count([]),
|
||||
'unresolvedComments' => $unresolvedComments,
|
||||
], JSON_THROW_ON_ERROR))];
|
||||
}
|
||||
}
|
||||
100
src/Mcp/Security/McpHeaderAuthenticator.php
Normal file
100
src/Mcp/Security/McpHeaderAuthenticator.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Security;
|
||||
|
||||
use App\Entity\Profile;
|
||||
use App\Repository\ProfileRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||
|
||||
final class McpHeaderAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly RateLimiterFactory $mcpAuthLimiter,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
if (!$request->headers->has('X-Profile-Id') || !$request->headers->has('X-Profile-Password')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
$profileId = $request->headers->get('X-Profile-Id', '');
|
||||
$password = $request->headers->get('X-Profile-Password', '');
|
||||
|
||||
$limiter = $this->mcpAuthLimiter->create($request->getClientIp() ?? 'unknown');
|
||||
$limit = $limiter->consume(1);
|
||||
|
||||
if (!$limit->isAccepted()) {
|
||||
$this->logger->warning('MCP auth rate limited', ['ip' => $request->getClientIp()]);
|
||||
|
||||
throw new CustomUserMessageAuthenticationException('Rate limited: too many authentication attempts.');
|
||||
}
|
||||
|
||||
return new SelfValidatingPassport(
|
||||
new UserBadge($profileId, function (string $id) use ($password, $limiter, $request): Profile {
|
||||
$profile = $this->profiles->find($id);
|
||||
|
||||
if (!$profile || !$profile->isActive()) {
|
||||
$this->logger->warning('MCP auth failed: profile not found', ['profileId' => $id]);
|
||||
|
||||
throw new CustomUserMessageAuthenticationException('Authentication failed: invalid credentials.');
|
||||
}
|
||||
|
||||
if (!$this->passwordHasher->isPasswordValid($profile, $password)) {
|
||||
$this->logger->warning('MCP auth failed: invalid password', ['profileId' => $id]);
|
||||
|
||||
throw new CustomUserMessageAuthenticationException('Authentication failed: invalid credentials.');
|
||||
}
|
||||
|
||||
$limiter->reset();
|
||||
|
||||
$this->logger->info('MCP auth success', [
|
||||
'profileId' => $id,
|
||||
'roles' => $profile->getRoles(),
|
||||
'ip' => $request->getClientIp(),
|
||||
]);
|
||||
|
||||
return $profile;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||
{
|
||||
$statusCode = str_contains($exception->getMessageKey(), 'Rate limited')
|
||||
? Response::HTTP_TOO_MANY_REQUESTS
|
||||
: Response::HTTP_UNAUTHORIZED;
|
||||
|
||||
return new JsonResponse(
|
||||
['message' => $exception->getMessageKey()],
|
||||
$statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
71
src/Mcp/Security/McpStdioAuthSubscriber.php
Normal file
71
src/Mcp/Security/McpStdioAuthSubscriber.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Security;
|
||||
|
||||
use App\Repository\ProfileRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\ConsoleEvents;
|
||||
use Symfony\Component\Console\Event\ConsoleCommandEvent;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
|
||||
#[AsEventListener(event: ConsoleEvents::COMMAND)]
|
||||
final class McpStdioAuthSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly TokenStorageInterface $tokenStorage,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function __invoke(ConsoleCommandEvent $event): void
|
||||
{
|
||||
$command = $event->getCommand();
|
||||
|
||||
if (!$command || !str_starts_with($command->getName() ?? '', 'mcp:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$profileId = $_ENV['MCP_PROFILE_ID'] ?? '';
|
||||
$password = $_ENV['MCP_PROFILE_PASSWORD'] ?? '';
|
||||
|
||||
if ('' === $profileId || '' === $password) {
|
||||
$this->logger->error('MCP stdio: missing MCP_PROFILE_ID or MCP_PROFILE_PASSWORD env vars');
|
||||
$event->disableCommand();
|
||||
$event->getOutput()->writeln('<error>MCP auth: MCP_PROFILE_ID and MCP_PROFILE_PASSWORD env vars required</error>');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$profile = $this->profiles->find($profileId);
|
||||
|
||||
if (!$profile || !$profile->isActive()) {
|
||||
$this->logger->error('MCP stdio: profile not found or inactive', ['profileId' => $profileId]);
|
||||
$event->disableCommand();
|
||||
$event->getOutput()->writeln('<error>MCP auth: invalid profile</error>');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->passwordHasher->isPasswordValid($profile, $password)) {
|
||||
$this->logger->error('MCP stdio: invalid password', ['profileId' => $profileId]);
|
||||
$event->disableCommand();
|
||||
$event->getOutput()->writeln('<error>MCP auth: invalid password</error>');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$token = new UsernamePasswordToken($profile, 'mcp', $profile->getRoles());
|
||||
$this->tokenStorage->setToken($token);
|
||||
|
||||
$this->logger->info('MCP stdio auth success', [
|
||||
'profileId' => $profileId,
|
||||
'roles' => $profile->getRoles(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
61
src/Mcp/Tool/ActivityLogTool.php
Normal file
61
src/Mcp/Tool/ActivityLogTool.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use DateTimeInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_activity_log',
|
||||
description: 'Get the global activity log with optional filters. Returns paginated audit entries across all entities.',
|
||||
)]
|
||||
class ActivityLogTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $page = 1, int $limit = 30, string $entityType = '', string $action = ''): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_VIEWER');
|
||||
|
||||
$p = $this->paginationParams($page, $limit);
|
||||
|
||||
$filters = [];
|
||||
if ('' !== $entityType) {
|
||||
$filters['entityType'] = $entityType;
|
||||
}
|
||||
if ('' !== $action) {
|
||||
$filters['action'] = $action;
|
||||
}
|
||||
|
||||
$result = $this->auditLogs->findAllPaginated($p['page'], $p['limit'], $filters);
|
||||
|
||||
$items = array_map(
|
||||
static function ($log) {
|
||||
$snapshot = $log->getSnapshot();
|
||||
|
||||
return [
|
||||
'id' => $log->getId(),
|
||||
'entityType' => $log->getEntityType(),
|
||||
'entityId' => $log->getEntityId(),
|
||||
'entityName' => $snapshot['name'] ?? null,
|
||||
'action' => $log->getAction(),
|
||||
'diff' => $log->getDiff(),
|
||||
'actorProfileId' => $log->getActorProfileId(),
|
||||
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
];
|
||||
},
|
||||
$result['items'],
|
||||
);
|
||||
|
||||
return $this->paginatedResponse(array_values($items), $result['total'], $p['page'], $p['limit']);
|
||||
}
|
||||
}
|
||||
85
src/Mcp/Tool/Comment/CreateCommentTool.php
Normal file
85
src/Mcp/Tool/Comment/CreateCommentTool.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Comment;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ProfileRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'create_comment',
|
||||
description: 'Create a comment on an entity (machine, piece, composant, product…). Requires ROLE_VIEWER.',
|
||||
)]
|
||||
class CreateCommentTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $content,
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
string $entityName = '',
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_VIEWER');
|
||||
|
||||
$content = trim($content);
|
||||
if ('' === $content) {
|
||||
$this->mcpError('Validation', 'Le contenu est requis.');
|
||||
}
|
||||
|
||||
$allowedTypes = ['machine', 'piece', 'composant', 'product', 'piece_category', 'component_category', 'product_category', 'machine_skeleton'];
|
||||
if (!in_array($entityType, $allowedTypes, true)) {
|
||||
$this->mcpError('Validation', "Type d'entité invalide : {$entityType}.");
|
||||
}
|
||||
|
||||
$entityId = trim($entityId);
|
||||
if ('' === $entityId) {
|
||||
$this->mcpError('Validation', "L'identifiant de l'entité est requis.");
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
$profile = $user ? $this->profiles->find($user->getUserIdentifier()) : null;
|
||||
|
||||
$authorName = 'Inconnu';
|
||||
$authorId = '';
|
||||
if ($profile) {
|
||||
$authorId = $profile->getId();
|
||||
$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 ? $entityName : null);
|
||||
$comment->setAuthorId($authorId);
|
||||
$comment->setAuthorName($authorName);
|
||||
|
||||
$this->em->persist($comment);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $comment->getId(),
|
||||
'content' => $comment->getContent(),
|
||||
'entityType' => $comment->getEntityType(),
|
||||
'entityId' => $comment->getEntityId(),
|
||||
'entityName' => $comment->getEntityName(),
|
||||
'authorName' => $comment->getAuthorName(),
|
||||
'status' => $comment->getStatus(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
67
src/Mcp/Tool/Comment/ListCommentsTool.php
Normal file
67
src/Mcp/Tool/Comment/ListCommentsTool.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Comment;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_comments',
|
||||
description: 'List comments for a given entity (machine, piece, composant, product…) with pagination. Filter by entityType and entityId.',
|
||||
)]
|
||||
class ListCommentsTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $entityType, string $entityId, int $page = 1, int $limit = 30): array
|
||||
{
|
||||
$p = $this->paginationParams($page, $limit);
|
||||
|
||||
$repo = $this->em->getRepository(Comment::class);
|
||||
|
||||
$total = (int) $repo->createQueryBuilder('c')
|
||||
->select('COUNT(c.id)')
|
||||
->andWhere('c.entityType = :entityType')
|
||||
->andWhere('c.entityId = :entityId')
|
||||
->setParameter('entityType', $entityType)
|
||||
->setParameter('entityId', $entityId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
|
||||
$items = $repo->createQueryBuilder('c')
|
||||
->select(
|
||||
'c.id',
|
||||
'c.content',
|
||||
'c.entityType',
|
||||
'c.entityId',
|
||||
'c.entityName',
|
||||
'c.authorName',
|
||||
'c.authorId',
|
||||
'c.status',
|
||||
'c.resolvedByName',
|
||||
'c.resolvedAt',
|
||||
'c.createdAt',
|
||||
)
|
||||
->andWhere('c.entityType = :entityType')
|
||||
->andWhere('c.entityId = :entityId')
|
||||
->setParameter('entityType', $entityType)
|
||||
->setParameter('entityId', $entityId)
|
||||
->orderBy('c.createdAt', 'DESC')
|
||||
->setFirstResult($p['offset'])
|
||||
->setMaxResults($p['limit'])
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
|
||||
}
|
||||
}
|
||||
65
src/Mcp/Tool/Comment/ResolveCommentTool.php
Normal file
65
src/Mcp/Tool/Comment/ResolveCommentTool.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Comment;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'resolve_comment',
|
||||
description: 'Mark a comment as resolved. Sets status to "resolved" with resolver info. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class ResolveCommentTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $commentId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$comment = $this->em->getRepository(Comment::class)->find($commentId);
|
||||
if (!$comment) {
|
||||
$this->mcpError('NotFound', 'Commentaire introuvable.');
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
$profile = $user ? $this->profiles->find($user->getUserIdentifier()) : null;
|
||||
|
||||
$resolverName = 'Inconnu';
|
||||
$resolverId = null;
|
||||
if ($profile) {
|
||||
$resolverId = $profile->getId();
|
||||
$resolverName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ('' === $resolverName) {
|
||||
$resolverName = $profile->getEmail() ?? 'Inconnu';
|
||||
}
|
||||
}
|
||||
|
||||
$comment->setStatus('resolved');
|
||||
$comment->setResolvedById($resolverId);
|
||||
$comment->setResolvedByName($resolverName);
|
||||
$comment->setResolvedAt(new DateTimeImmutable());
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $comment->getId(),
|
||||
'status' => $comment->getStatus(),
|
||||
'resolvedById' => $comment->getResolvedById(),
|
||||
'resolvedByName' => $comment->getResolvedByName(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
37
src/Mcp/Tool/Comment/UnresolvedCountTool.php
Normal file
37
src/Mcp/Tool/Comment/UnresolvedCountTool.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Comment;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_unresolved_comments_count',
|
||||
description: 'Get the total count of unresolved (open) comments across all entities.',
|
||||
)]
|
||||
class UnresolvedCountTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function __invoke(): array
|
||||
{
|
||||
$count = (int) $this->em->getRepository(Comment::class)
|
||||
->createQueryBuilder('c')
|
||||
->select('COUNT(c.id)')
|
||||
->andWhere('c.status = :status')
|
||||
->setParameter('status', 'open')
|
||||
->getQuery()
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
|
||||
return $this->jsonResponse(['count' => $count]);
|
||||
}
|
||||
}
|
||||
80
src/Mcp/Tool/Composant/CreateComposantTool.php
Normal file
80
src/Mcp/Tool/Composant/CreateComposantTool.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Composant;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'create_composant',
|
||||
description: 'Create a new composant. prix must be a string (e.g. "12.50"). Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class CreateComposantTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string[] $constructeurIds
|
||||
*/
|
||||
public function __invoke(
|
||||
string $name,
|
||||
string $reference = '',
|
||||
string $description = '',
|
||||
string $prix = '',
|
||||
string $modelTypeId = '',
|
||||
array $constructeurIds = [],
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$composant = new Composant();
|
||||
$composant->setName($name);
|
||||
|
||||
if ('' !== $reference) {
|
||||
$composant->setReference($reference);
|
||||
}
|
||||
if ('' !== $description) {
|
||||
$composant->setDescription($description);
|
||||
}
|
||||
if ('' !== $prix) {
|
||||
$composant->setPrix($prix);
|
||||
}
|
||||
|
||||
if ('' !== $modelTypeId) {
|
||||
$modelType = $this->modelTypes->find($modelTypeId);
|
||||
if (!$modelType) {
|
||||
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
|
||||
}
|
||||
$composant->setTypeComposant($modelType);
|
||||
}
|
||||
|
||||
foreach ($constructeurIds as $cId) {
|
||||
$c = $this->constructeurs->find($cId);
|
||||
if (!$c) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$cId}");
|
||||
}
|
||||
$composant->addConstructeur($c);
|
||||
}
|
||||
|
||||
$this->em->persist($composant);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $composant->getId(),
|
||||
'name' => $composant->getName(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
src/Mcp/Tool/Composant/DeleteComposantTool.php
Normal file
42
src/Mcp/Tool/Composant/DeleteComposantTool.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Composant;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ComposantRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'delete_composant',
|
||||
description: 'Delete a composant by ID. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class DeleteComposantTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ComposantRepository $composants,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $composantId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$composant = $this->composants->find($composantId);
|
||||
|
||||
if (!$composant) {
|
||||
$this->mcpError('not_found', "Composant not found: {$composantId}");
|
||||
}
|
||||
|
||||
$this->em->remove($composant);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $composantId]);
|
||||
}
|
||||
}
|
||||
59
src/Mcp/Tool/Composant/GetComposantTool.php
Normal file
59
src/Mcp/Tool/Composant/GetComposantTool.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Composant;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ComposantRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_composant',
|
||||
description: 'Get a single composant by ID with all its details, including typeComposant and constructeurs.',
|
||||
)]
|
||||
class GetComposantTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ComposantRepository $composants,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $composantId): array
|
||||
{
|
||||
$composant = $this->composants->find($composantId);
|
||||
|
||||
if (!$composant) {
|
||||
$this->mcpError('not_found', "Composant not found: {$composantId}");
|
||||
}
|
||||
|
||||
$constructeurs = [];
|
||||
foreach ($composant->getConstructeurs() as $c) {
|
||||
$constructeurs[] = [
|
||||
'id' => $c->getId(),
|
||||
'name' => $c->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
$typeComposant = null;
|
||||
if ($composant->getTypeComposant()) {
|
||||
$typeComposant = [
|
||||
'id' => $composant->getTypeComposant()->getId(),
|
||||
'name' => $composant->getTypeComposant()->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $composant->getId(),
|
||||
'name' => $composant->getName(),
|
||||
'reference' => $composant->getReference(),
|
||||
'description' => $composant->getDescription(),
|
||||
'prix' => $composant->getPrix(),
|
||||
'typeComposant' => $typeComposant,
|
||||
'constructeurs' => $constructeurs,
|
||||
'createdAt' => $composant->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'updatedAt' => $composant->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
src/Mcp/Tool/Composant/ListComposantsTool.php
Normal file
55
src/Mcp/Tool/Composant/ListComposantsTool.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Composant;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ComposantRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_composants',
|
||||
description: 'List composants with pagination. Filterable by name or reference.',
|
||||
)]
|
||||
class ListComposantsTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ComposantRepository $composants,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): array
|
||||
{
|
||||
$p = $this->paginationParams($page, $limit);
|
||||
|
||||
$countQb = $this->composants->createQueryBuilder('c')
|
||||
->select('COUNT(c.id)')
|
||||
;
|
||||
|
||||
$qb = $this->composants->createQueryBuilder('c')
|
||||
->select('c.id', 'c.name', 'c.reference', 'c.prix')
|
||||
->orderBy('c.name', 'ASC')
|
||||
;
|
||||
|
||||
if ('' !== $search) {
|
||||
$countQb->andWhere('LOWER(c.name) LIKE LOWER(:search) OR LOWER(c.reference) LIKE LOWER(:search)')
|
||||
->setParameter('search', "%{$search}%")
|
||||
;
|
||||
$qb->andWhere('LOWER(c.name) LIKE LOWER(:search) OR LOWER(c.reference) LIKE LOWER(:search)')
|
||||
->setParameter('search', "%{$search}%")
|
||||
;
|
||||
}
|
||||
|
||||
$total = (int) $countQb->getQuery()->getSingleScalarResult();
|
||||
|
||||
$items = $qb->setFirstResult($p['offset'])
|
||||
->setMaxResults($p['limit'])
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
|
||||
}
|
||||
}
|
||||
93
src/Mcp/Tool/Composant/UpdateComposantTool.php
Normal file
93
src/Mcp/Tool/Composant/UpdateComposantTool.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Composant;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'update_composant',
|
||||
description: 'Update an existing composant. Only provided fields are changed. prix must be a string. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class UpdateComposantTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ComposantRepository $composants,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param null|string[] $constructeurIds
|
||||
*/
|
||||
public function __invoke(
|
||||
string $composantId,
|
||||
?string $name = null,
|
||||
?string $reference = null,
|
||||
?string $description = null,
|
||||
?string $prix = null,
|
||||
?string $modelTypeId = null,
|
||||
?array $constructeurIds = null,
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$composant = $this->composants->find($composantId);
|
||||
|
||||
if (!$composant) {
|
||||
$this->mcpError('not_found', "Composant not found: {$composantId}");
|
||||
}
|
||||
|
||||
if (null !== $name) {
|
||||
$composant->setName($name);
|
||||
}
|
||||
if (null !== $reference) {
|
||||
$composant->setReference($reference);
|
||||
}
|
||||
if (null !== $description) {
|
||||
$composant->setDescription($description);
|
||||
}
|
||||
if (null !== $prix) {
|
||||
$composant->setPrix($prix);
|
||||
}
|
||||
|
||||
if (null !== $modelTypeId) {
|
||||
if ('' === $modelTypeId) {
|
||||
$composant->setTypeComposant(null);
|
||||
} else {
|
||||
$modelType = $this->modelTypes->find($modelTypeId);
|
||||
if (!$modelType) {
|
||||
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
|
||||
}
|
||||
$composant->setTypeComposant($modelType);
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $constructeurIds) {
|
||||
foreach ($composant->getConstructeurs()->toArray() as $existing) {
|
||||
$composant->removeConstructeur($existing);
|
||||
}
|
||||
foreach ($constructeurIds as $cId) {
|
||||
$c = $this->constructeurs->find($cId);
|
||||
if (!$c) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$cId}");
|
||||
}
|
||||
$composant->addConstructeur($c);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['id' => $composant->getId(), 'name' => $composant->getName()]);
|
||||
}
|
||||
}
|
||||
46
src/Mcp/Tool/Constructeur/CreateConstructeurTool.php
Normal file
46
src/Mcp/Tool/Constructeur/CreateConstructeurTool.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Constructeur;
|
||||
|
||||
use App\Entity\Constructeur;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'create_constructeur',
|
||||
description: 'Create a new constructeur (manufacturer/supplier). Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class CreateConstructeurTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $name,
|
||||
string $email = '',
|
||||
string $phone = '',
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$constructeur = new Constructeur();
|
||||
$constructeur->setName($name);
|
||||
$constructeur->setEmail('' !== $email ? $email : null);
|
||||
$constructeur->setPhone('' !== $phone ? $phone : null);
|
||||
|
||||
$this->em->persist($constructeur);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $constructeur->getId(),
|
||||
'name' => $constructeur->getName(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
src/Mcp/Tool/Constructeur/DeleteConstructeurTool.php
Normal file
42
src/Mcp/Tool/Constructeur/DeleteConstructeurTool.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Constructeur;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'delete_constructeur',
|
||||
description: 'Delete a constructeur by ID. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class DeleteConstructeurTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $constructeurId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$constructeur = $this->constructeurs->find($constructeurId);
|
||||
|
||||
if (!$constructeur) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
|
||||
}
|
||||
|
||||
$this->em->remove($constructeur);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $constructeurId]);
|
||||
}
|
||||
}
|
||||
40
src/Mcp/Tool/Constructeur/GetConstructeurTool.php
Normal file
40
src/Mcp/Tool/Constructeur/GetConstructeurTool.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Constructeur;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_constructeur',
|
||||
description: 'Get a single constructeur (manufacturer/supplier) by ID with all its details.',
|
||||
)]
|
||||
class GetConstructeurTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $constructeurId): array
|
||||
{
|
||||
$constructeur = $this->constructeurs->find($constructeurId);
|
||||
|
||||
if (!$constructeur) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $constructeur->getId(),
|
||||
'name' => $constructeur->getName(),
|
||||
'email' => $constructeur->getEmail(),
|
||||
'phone' => $constructeur->getPhone(),
|
||||
'createdAt' => $constructeur->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
src/Mcp/Tool/Constructeur/ListConstructeursTool.php
Normal file
55
src/Mcp/Tool/Constructeur/ListConstructeursTool.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Constructeur;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_constructeurs',
|
||||
description: 'List all constructeurs (manufacturers/suppliers) with pagination. Filterable by name.',
|
||||
)]
|
||||
class ListConstructeursTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): array
|
||||
{
|
||||
$p = $this->paginationParams($page, $limit);
|
||||
|
||||
$countQb = $this->constructeurs->createQueryBuilder('c')
|
||||
->select('COUNT(c.id)')
|
||||
;
|
||||
|
||||
$qb = $this->constructeurs->createQueryBuilder('c')
|
||||
->select('c.id', 'c.name', 'c.email', 'c.phone')
|
||||
->orderBy('c.name', 'ASC')
|
||||
;
|
||||
|
||||
if ('' !== $search) {
|
||||
$countQb->andWhere('LOWER(c.name) LIKE LOWER(:search)')
|
||||
->setParameter('search', "%{$search}%")
|
||||
;
|
||||
$qb->andWhere('LOWER(c.name) LIKE LOWER(:search)')
|
||||
->setParameter('search', "%{$search}%")
|
||||
;
|
||||
}
|
||||
|
||||
$total = (int) $countQb->getQuery()->getSingleScalarResult();
|
||||
|
||||
$items = $qb->setFirstResult($p['offset'])
|
||||
->setMaxResults($p['limit'])
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
|
||||
}
|
||||
}
|
||||
55
src/Mcp/Tool/Constructeur/UpdateConstructeurTool.php
Normal file
55
src/Mcp/Tool/Constructeur/UpdateConstructeurTool.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Constructeur;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'update_constructeur',
|
||||
description: 'Update an existing constructeur. Only provided fields are changed. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class UpdateConstructeurTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $constructeurId,
|
||||
?string $name = null,
|
||||
?string $email = null,
|
||||
?string $phone = null,
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$constructeur = $this->constructeurs->find($constructeurId);
|
||||
|
||||
if (!$constructeur) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
|
||||
}
|
||||
|
||||
if (null !== $name) {
|
||||
$constructeur->setName($name);
|
||||
}
|
||||
if (null !== $email) {
|
||||
$constructeur->setEmail($email);
|
||||
}
|
||||
if (null !== $phone) {
|
||||
$constructeur->setPhone($phone);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['id' => $constructeur->getId(), 'name' => $constructeur->getName()]);
|
||||
}
|
||||
}
|
||||
41
src/Mcp/Tool/CustomField/DeleteCustomFieldValueTool.php
Normal file
41
src/Mcp/Tool/CustomField/DeleteCustomFieldValueTool.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\CustomField;
|
||||
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'delete_custom_field_value',
|
||||
description: 'Delete a custom field value by ID. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class DeleteCustomFieldValueTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $customFieldValueId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$cfv = $this->em->getRepository(CustomFieldValue::class)->find($customFieldValueId);
|
||||
|
||||
if (null === $cfv) {
|
||||
$this->mcpError('not_found', "CustomFieldValue not found: {$customFieldValueId}");
|
||||
}
|
||||
|
||||
$this->em->remove($cfv);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $customFieldValueId]);
|
||||
}
|
||||
}
|
||||
61
src/Mcp/Tool/CustomField/ListCustomFieldValuesTool.php
Normal file
61
src/Mcp/Tool/CustomField/ListCustomFieldValuesTool.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\CustomField;
|
||||
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_custom_field_values',
|
||||
description: 'List all custom field values for a given entity (machine, composant, piece or product). Returns each value with its custom field name and type.',
|
||||
)]
|
||||
class ListCustomFieldValuesTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
private const ALLOWED_TYPES = ['machine', 'composant', 'piece', 'product'];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $entityType, string $entityId): array
|
||||
{
|
||||
$entityType = strtolower($entityType);
|
||||
|
||||
if (!in_array($entityType, self::ALLOWED_TYPES, true)) {
|
||||
$this->mcpError('validation', "entityType must be one of: machine, composant, piece, product. Got '{$entityType}'.");
|
||||
}
|
||||
|
||||
$rows = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'cfv.id',
|
||||
'cfv.value',
|
||||
'cf.id AS customFieldId',
|
||||
'cf.name AS customFieldName',
|
||||
'cf.type AS customFieldType',
|
||||
'cf.required AS customFieldRequired',
|
||||
'cfv.createdAt',
|
||||
'cfv.updatedAt',
|
||||
)
|
||||
->from(CustomFieldValue::class, 'cfv')
|
||||
->join('cfv.customField', 'cf')
|
||||
->where("IDENTITY(cfv.{$entityType}) = :entityId")
|
||||
->setParameter('entityId', $entityId)
|
||||
->orderBy('cf.name', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return $this->jsonResponse([
|
||||
'entityType' => $entityType,
|
||||
'entityId' => $entityId,
|
||||
'values' => $rows,
|
||||
'total' => count($rows),
|
||||
]);
|
||||
}
|
||||
}
|
||||
114
src/Mcp/Tool/CustomField/UpsertCustomFieldValuesTool.php
Normal file
114
src/Mcp/Tool/CustomField/UpsertCustomFieldValuesTool.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\CustomField;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Machine;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\Product;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'upsert_custom_field_values',
|
||||
description: 'Create or update custom field values for a given entity. Each entry in the fields array needs customFieldId and value. If a value already exists for that custom field + entity, it is updated; otherwise a new one is created. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class UpsertCustomFieldValuesTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
private const ALLOWED_TYPES = ['machine', 'composant', 'piece', 'product'];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $entityType, string $entityId, array $fields): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$entityType = strtolower($entityType);
|
||||
|
||||
if (!in_array($entityType, self::ALLOWED_TYPES, true)) {
|
||||
$this->mcpError('validation', "entityType must be one of: machine, composant, piece, product. Got '{$entityType}'.");
|
||||
}
|
||||
|
||||
$entityClass = match ($entityType) {
|
||||
'machine' => Machine::class,
|
||||
'composant' => Composant::class,
|
||||
'piece' => Piece::class,
|
||||
'product' => Product::class,
|
||||
};
|
||||
|
||||
$entity = $this->em->getRepository($entityClass)->find($entityId);
|
||||
|
||||
if (null === $entity) {
|
||||
$this->mcpError('not_found', ucfirst($entityType)." not found: {$entityId}");
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($fields as $fieldEntry) {
|
||||
$customFieldId = $fieldEntry['customFieldId'] ?? null;
|
||||
$value = $fieldEntry['value'] ?? '';
|
||||
|
||||
if (null === $customFieldId) {
|
||||
$this->mcpError('validation', 'Each field entry must have a customFieldId.');
|
||||
}
|
||||
|
||||
$customField = $this->em->getRepository(CustomField::class)->find($customFieldId);
|
||||
|
||||
if (null === $customField) {
|
||||
$this->mcpError('not_found', "CustomField not found: {$customFieldId}");
|
||||
}
|
||||
|
||||
$existing = $this->em->getRepository(CustomFieldValue::class)->findOneBy([
|
||||
'customField' => $customField,
|
||||
$entityType => $entity,
|
||||
]);
|
||||
|
||||
if (null !== $existing) {
|
||||
$existing->setValue((string) $value);
|
||||
$results[] = [
|
||||
'id' => $existing->getId(),
|
||||
'customFieldId' => $customField->getId(),
|
||||
'value' => (string) $value,
|
||||
'action' => 'updated',
|
||||
];
|
||||
} else {
|
||||
$cfv = new CustomFieldValue();
|
||||
$cfv->setCustomField($customField);
|
||||
$cfv->setValue((string) $value);
|
||||
|
||||
$setter = 'set'.ucfirst($entityType);
|
||||
$cfv->{$setter}($entity);
|
||||
|
||||
$this->em->persist($cfv);
|
||||
$this->em->flush();
|
||||
|
||||
$results[] = [
|
||||
'id' => $cfv->getId(),
|
||||
'customFieldId' => $customField->getId(),
|
||||
'value' => (string) $value,
|
||||
'action' => 'created',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'entityType' => $entityType,
|
||||
'entityId' => $entityId,
|
||||
'results' => $results,
|
||||
'total' => count($results),
|
||||
]);
|
||||
}
|
||||
}
|
||||
50
src/Mcp/Tool/DashboardStatsTool.php
Normal file
50
src/Mcp/Tool/DashboardStatsTool.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool;
|
||||
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\SiteRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_dashboard_stats',
|
||||
description: 'Get global inventory statistics: count of machines, pieces, composants, products, sites, and unresolved comments. Takes no parameters.',
|
||||
)]
|
||||
class DashboardStatsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly PieceRepository $pieces,
|
||||
private readonly ComposantRepository $composants,
|
||||
private readonly ProductRepository $products,
|
||||
private readonly SiteRepository $sites,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function __invoke(): array
|
||||
{
|
||||
$unresolvedComments = (int) $this->em->createQuery(
|
||||
"SELECT COUNT(c.id) FROM App\\Entity\\Comment c WHERE c.status = 'open'"
|
||||
)->getSingleScalarResult();
|
||||
|
||||
return [
|
||||
new TextContent(
|
||||
text: json_encode([
|
||||
'machines' => $this->machines->count([]),
|
||||
'pieces' => $this->pieces->count([]),
|
||||
'composants' => $this->composants->count([]),
|
||||
'products' => $this->products->count([]),
|
||||
'sites' => $this->sites->count([]),
|
||||
'unresolvedComments' => $unresolvedComments,
|
||||
], JSON_THROW_ON_ERROR)
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
42
src/Mcp/Tool/Document/DeleteDocumentTool.php
Normal file
42
src/Mcp/Tool/Document/DeleteDocumentTool.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Document;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\DocumentRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'delete_document',
|
||||
description: 'Delete a document by ID. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class DeleteDocumentTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly DocumentRepository $documents,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $documentId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$document = $this->documents->find($documentId);
|
||||
|
||||
if (!$document) {
|
||||
$this->mcpError('not_found', "Document not found: {$documentId}");
|
||||
}
|
||||
|
||||
$this->em->remove($document);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $documentId]);
|
||||
}
|
||||
}
|
||||
62
src/Mcp/Tool/Document/ListDocumentsTool.php
Normal file
62
src/Mcp/Tool/Document/ListDocumentsTool.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Document;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\DocumentRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_documents',
|
||||
description: 'List documents attached to a given entity. entityType must be one of: site, machine, composant, piece, product.',
|
||||
)]
|
||||
class ListDocumentsTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
private const ENTITY_FIELDS = [
|
||||
'site' => 'site',
|
||||
'machine' => 'machine',
|
||||
'composant' => 'composant',
|
||||
'piece' => 'piece',
|
||||
'product' => 'product',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly DocumentRepository $documents,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $entityType, string $entityId): array
|
||||
{
|
||||
if (!isset(self::ENTITY_FIELDS[$entityType])) {
|
||||
$this->mcpError('validation', "Invalid entityType '{$entityType}'. Must be one of: site, machine, composant, piece, product.");
|
||||
}
|
||||
|
||||
$field = self::ENTITY_FIELDS[$entityType];
|
||||
|
||||
$docs = $this->documents->findBy([$field => $entityId], ['createdAt' => 'DESC']);
|
||||
|
||||
$items = [];
|
||||
foreach ($docs as $doc) {
|
||||
$items[] = [
|
||||
'id' => $doc->getId(),
|
||||
'name' => $doc->getName(),
|
||||
'filename' => $doc->getFilename(),
|
||||
'fileUrl' => '/api/documents/'.$doc->getId().'/file',
|
||||
'downloadUrl' => '/api/documents/'.$doc->getId().'/download',
|
||||
'mimeType' => $doc->getMimeType(),
|
||||
'size' => $doc->getSize(),
|
||||
'createdAt' => $doc->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'entityType' => $entityType,
|
||||
'entityId' => $entityId,
|
||||
'items' => $items,
|
||||
'total' => count($items),
|
||||
]);
|
||||
}
|
||||
}
|
||||
57
src/Mcp/Tool/EntityHistoryTool.php
Normal file
57
src/Mcp/Tool/EntityHistoryTool.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use DateTimeInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_entity_history',
|
||||
description: 'Get the audit history for a specific entity (machine, piece, composant, product). Returns list of changes with diffs.',
|
||||
)]
|
||||
class EntityHistoryTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
private const VALID_TYPES = ['machine', 'piece', 'composant', 'product'];
|
||||
|
||||
public function __construct(
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $entityType, string $entityId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_VIEWER');
|
||||
|
||||
if (!in_array($entityType, self::VALID_TYPES, true)) {
|
||||
$this->mcpError('Validation', sprintf(
|
||||
'Invalid entityType "%s". Must be one of: %s',
|
||||
$entityType,
|
||||
implode(', ', self::VALID_TYPES),
|
||||
));
|
||||
}
|
||||
|
||||
$logs = $this->auditLogs->findEntityHistory($entityType, $entityId, 200);
|
||||
|
||||
$items = array_map(
|
||||
static fn ($log) => [
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'diff' => $log->getDiff(),
|
||||
'actorProfileId' => $log->getActorProfileId(),
|
||||
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
],
|
||||
$logs,
|
||||
);
|
||||
|
||||
return $this->jsonResponse([
|
||||
'items' => array_values($items),
|
||||
'total' => count($items),
|
||||
]);
|
||||
}
|
||||
}
|
||||
162
src/Mcp/Tool/Machine/AddMachineLinksTool.php
Normal file
162
src/Mcp/Tool/Machine/AddMachineLinksTool.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Entity\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
use App\Entity\MachineProductLink;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'add_machine_links',
|
||||
description: 'Add one or more links (composant, piece, product) to a machine. Each link specifies a type, entityId, and optional parentLinkId / overrides. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class AddMachineLinksTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly ComposantRepository $composants,
|
||||
private readonly PieceRepository $pieces,
|
||||
private readonly ProductRepository $products,
|
||||
private readonly MachineComponentLinkRepository $componentLinks,
|
||||
private readonly MachinePieceLinkRepository $pieceLinks,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $machineId, array $links): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$machine = $this->machines->find($machineId);
|
||||
if (null === $machine) {
|
||||
$this->mcpError('NotFound', "Machine {$machineId} not found.");
|
||||
}
|
||||
|
||||
$created = [];
|
||||
|
||||
foreach ($links as $linkData) {
|
||||
$type = $linkData['type'] ?? '';
|
||||
$entityId = $linkData['entityId'] ?? '';
|
||||
|
||||
switch ($type) {
|
||||
case 'composant':
|
||||
$composant = $this->composants->find($entityId);
|
||||
if (null === $composant) {
|
||||
$this->mcpError('NotFound', "Composant {$entityId} not found.");
|
||||
}
|
||||
|
||||
$link = new MachineComponentLink();
|
||||
$link->setMachine($machine);
|
||||
$link->setComposant($composant);
|
||||
|
||||
if (!empty($linkData['parentLinkId'])) {
|
||||
$parent = $this->componentLinks->find($linkData['parentLinkId']);
|
||||
if (null !== $parent) {
|
||||
$link->setParentLink($parent);
|
||||
}
|
||||
}
|
||||
if (isset($linkData['nameOverride'])) {
|
||||
$link->setNameOverride($linkData['nameOverride']);
|
||||
}
|
||||
if (isset($linkData['referenceOverride'])) {
|
||||
$link->setReferenceOverride($linkData['referenceOverride']);
|
||||
}
|
||||
if (isset($linkData['prixOverride'])) {
|
||||
$link->setPrixOverride($linkData['prixOverride']);
|
||||
}
|
||||
|
||||
$this->em->persist($link);
|
||||
$created[] = ['id' => $link->getId(), 'type' => 'composant', 'entityId' => $entityId];
|
||||
|
||||
break;
|
||||
|
||||
case 'piece':
|
||||
$piece = $this->pieces->find($entityId);
|
||||
if (null === $piece) {
|
||||
$this->mcpError('NotFound', "Piece {$entityId} not found.");
|
||||
}
|
||||
|
||||
$link = new MachinePieceLink();
|
||||
$link->setMachine($machine);
|
||||
$link->setPiece($piece);
|
||||
$link->setQuantity((int) ($linkData['quantity'] ?? 1));
|
||||
|
||||
if (!empty($linkData['parentLinkId'])) {
|
||||
$parent = $this->componentLinks->find($linkData['parentLinkId']);
|
||||
if (null !== $parent) {
|
||||
$link->setParentLink($parent);
|
||||
}
|
||||
}
|
||||
if (isset($linkData['nameOverride'])) {
|
||||
$link->setNameOverride($linkData['nameOverride']);
|
||||
}
|
||||
if (isset($linkData['referenceOverride'])) {
|
||||
$link->setReferenceOverride($linkData['referenceOverride']);
|
||||
}
|
||||
if (isset($linkData['prixOverride'])) {
|
||||
$link->setPrixOverride($linkData['prixOverride']);
|
||||
}
|
||||
|
||||
$this->em->persist($link);
|
||||
$created[] = ['id' => $link->getId(), 'type' => 'piece', 'entityId' => $entityId];
|
||||
|
||||
break;
|
||||
|
||||
case 'product':
|
||||
$product = $this->products->find($entityId);
|
||||
if (null === $product) {
|
||||
$this->mcpError('NotFound', "Product {$entityId} not found.");
|
||||
}
|
||||
|
||||
$link = new MachineProductLink();
|
||||
$link->setMachine($machine);
|
||||
$link->setProduct($product);
|
||||
|
||||
if (!empty($linkData['parentLinkId'])) {
|
||||
$parentProduct = $this->em->getRepository(MachineProductLink::class)->find($linkData['parentLinkId']);
|
||||
if (null !== $parentProduct) {
|
||||
$link->setParentLink($parentProduct);
|
||||
}
|
||||
}
|
||||
if (!empty($linkData['parentComponentLinkId'])) {
|
||||
$parentComp = $this->componentLinks->find($linkData['parentComponentLinkId']);
|
||||
if (null !== $parentComp) {
|
||||
$link->setParentComponentLink($parentComp);
|
||||
}
|
||||
}
|
||||
if (!empty($linkData['parentPieceLinkId'])) {
|
||||
$parentPiece = $this->pieceLinks->find($linkData['parentPieceLinkId']);
|
||||
if (null !== $parentPiece) {
|
||||
$link->setParentPieceLink($parentPiece);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->persist($link);
|
||||
$created[] = ['id' => $link->getId(), 'type' => 'product', 'entityId' => $entityId];
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
$this->mcpError('Validation', "Unknown link type '{$type}'. Expected composant, piece, or product.");
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['created' => $created]);
|
||||
}
|
||||
}
|
||||
223
src/Mcp/Tool/Machine/CloneMachineTool.php
Normal file
223
src/Mcp/Tool/Machine/CloneMachineTool.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Machine;
|
||||
use App\Entity\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
use App\Entity\MachineProductLink;
|
||||
use App\Entity\Site;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use App\Repository\MachineProductLinkRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'clone_machine',
|
||||
description: 'Clone an existing machine with all its links (components, pieces, products), custom fields, and constructeurs. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class CloneMachineTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly MachineRepository $machineRepository,
|
||||
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
|
||||
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
|
||||
private readonly MachineProductLinkRepository $machineProductLinkRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $machineId,
|
||||
string $name,
|
||||
string $siteId,
|
||||
string $reference = '',
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$source = $this->machineRepository->find($machineId);
|
||||
if (!$source instanceof Machine) {
|
||||
$this->mcpError('not_found', "Machine not found: {$machineId}");
|
||||
}
|
||||
|
||||
$site = $this->entityManager->getRepository(Site::class)->find($siteId);
|
||||
if (!$site) {
|
||||
$this->mcpError('not_found', "Site not found: {$siteId}");
|
||||
}
|
||||
|
||||
// Create new machine
|
||||
$newMachine = new Machine();
|
||||
$newMachine->setName($name);
|
||||
$newMachine->setSite($site);
|
||||
if ('' !== $reference) {
|
||||
$newMachine->setReference($reference);
|
||||
}
|
||||
$newMachine->setPrix($source->getPrix());
|
||||
|
||||
// Copy constructeurs
|
||||
foreach ($source->getConstructeurs() as $constructeur) {
|
||||
$newMachine->getConstructeurs()->add($constructeur);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($newMachine);
|
||||
|
||||
// Copy custom fields and values
|
||||
$this->cloneCustomFields($source, $newMachine);
|
||||
|
||||
// Copy component links (preserving hierarchy with two-pass)
|
||||
$componentLinkMap = $this->cloneComponentLinks($source, $newMachine);
|
||||
|
||||
// Copy piece links
|
||||
$pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap);
|
||||
|
||||
// Copy product links
|
||||
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $newMachine->getId(),
|
||||
'name' => $newMachine->getName(),
|
||||
'reference' => $newMachine->getReference(),
|
||||
'siteId' => $site->getId(),
|
||||
'clonedFrom' => $source->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function cloneCustomFields(Machine $source, Machine $target): void
|
||||
{
|
||||
foreach ($source->getCustomFields() as $cf) {
|
||||
$newCf = new CustomField();
|
||||
$newCf->setName($cf->getName());
|
||||
$newCf->setType($cf->getType());
|
||||
$newCf->setRequired($cf->isRequired());
|
||||
$newCf->setDefaultValue($cf->getDefaultValue());
|
||||
$newCf->setOptions($cf->getOptions());
|
||||
$newCf->setOrderIndex($cf->getOrderIndex());
|
||||
$newCf->setMachine($target);
|
||||
$this->entityManager->persist($newCf);
|
||||
}
|
||||
|
||||
foreach ($source->getCustomFieldValues() as $cfv) {
|
||||
$newValue = new CustomFieldValue();
|
||||
$newValue->setMachine($target);
|
||||
$newValue->setCustomField($cfv->getCustomField());
|
||||
$newValue->setValue($cfv->getValue());
|
||||
$this->entityManager->persist($newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, MachineComponentLink> Map of old link ID to new link
|
||||
*/
|
||||
private function cloneComponentLinks(Machine $source, Machine $target): array
|
||||
{
|
||||
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
// First pass: create all links without parent relationships
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachineComponentLink();
|
||||
$newLink->setMachine($target);
|
||||
$newLink->setComposant($link->getComposant());
|
||||
$newLink->setNameOverride($link->getNameOverride());
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
$newLink->setPrixOverride($link->getPrixOverride());
|
||||
$this->entityManager->persist($newLink);
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
// Second pass: set parent relationships
|
||||
foreach ($sourceLinks as $link) {
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($linkMap[$parent->getId()])) {
|
||||
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
|
||||
}
|
||||
}
|
||||
|
||||
return $linkMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, MachineComponentLink> $componentLinkMap
|
||||
*
|
||||
* @return array<string, MachinePieceLink> Map of old link ID to new link
|
||||
*/
|
||||
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array
|
||||
{
|
||||
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachinePieceLink();
|
||||
$newLink->setMachine($target);
|
||||
$newLink->setPiece($link->getPiece());
|
||||
$newLink->setNameOverride($link->getNameOverride());
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
$newLink->setPrixOverride($link->getPrixOverride());
|
||||
$newLink->setQuantity($link->getQuantity());
|
||||
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($componentLinkMap[$parent->getId()])) {
|
||||
$newLink->setParentLink($componentLinkMap[$parent->getId()]);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($newLink);
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
return $linkMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, MachineComponentLink> $componentLinkMap
|
||||
* @param array<string, MachinePieceLink> $pieceLinkMap
|
||||
*/
|
||||
private function cloneProductLinks(
|
||||
Machine $source,
|
||||
Machine $target,
|
||||
array $componentLinkMap,
|
||||
array $pieceLinkMap,
|
||||
): void {
|
||||
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
// First pass: create all links
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachineProductLink();
|
||||
$newLink->setMachine($target);
|
||||
$newLink->setProduct($link->getProduct());
|
||||
|
||||
$parentComponent = $link->getParentComponentLink();
|
||||
if ($parentComponent && isset($componentLinkMap[$parentComponent->getId()])) {
|
||||
$newLink->setParentComponentLink($componentLinkMap[$parentComponent->getId()]);
|
||||
}
|
||||
|
||||
$parentPiece = $link->getParentPieceLink();
|
||||
if ($parentPiece && isset($pieceLinkMap[$parentPiece->getId()])) {
|
||||
$newLink->setParentPieceLink($pieceLinkMap[$parentPiece->getId()]);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($newLink);
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
// Second pass: set parent product link relationships
|
||||
foreach ($sourceLinks as $link) {
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($linkMap[$parent->getId()])) {
|
||||
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
74
src/Mcp/Tool/Machine/CreateMachineTool.php
Normal file
74
src/Mcp/Tool/Machine/CreateMachineTool.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Entity\Machine;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use App\Repository\SiteRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'create_machine',
|
||||
description: 'Create a new machine. siteId is required. prix must be a string (e.g. "12.50"). Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class CreateMachineTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly SiteRepository $sites,
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string[] $constructeurIds
|
||||
*/
|
||||
public function __invoke(
|
||||
string $name,
|
||||
string $siteId,
|
||||
string $reference = '',
|
||||
string $prix = '',
|
||||
array $constructeurIds = [],
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$site = $this->sites->find($siteId);
|
||||
if (!$site) {
|
||||
$this->mcpError('not_found', "Site not found: {$siteId}");
|
||||
}
|
||||
|
||||
$machine = new Machine();
|
||||
$machine->setName($name);
|
||||
$machine->setSite($site);
|
||||
|
||||
if ('' !== $reference) {
|
||||
$machine->setReference($reference);
|
||||
}
|
||||
if ('' !== $prix) {
|
||||
$machine->setPrix($prix);
|
||||
}
|
||||
|
||||
foreach ($constructeurIds as $cId) {
|
||||
$c = $this->constructeurs->find($cId);
|
||||
if (!$c) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$cId}");
|
||||
}
|
||||
$machine->addConstructeur($c);
|
||||
}
|
||||
|
||||
$this->em->persist($machine);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $machine->getId(),
|
||||
'name' => $machine->getName(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
src/Mcp/Tool/Machine/DeleteMachineTool.php
Normal file
42
src/Mcp/Tool/Machine/DeleteMachineTool.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'delete_machine',
|
||||
description: 'Delete a machine by ID. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class DeleteMachineTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $machineId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$machine = $this->machines->find($machineId);
|
||||
|
||||
if (!$machine) {
|
||||
$this->mcpError('not_found', "Machine not found: {$machineId}");
|
||||
}
|
||||
|
||||
$this->em->remove($machine);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $machineId]);
|
||||
}
|
||||
}
|
||||
58
src/Mcp/Tool/Machine/GetMachineTool.php
Normal file
58
src/Mcp/Tool/Machine/GetMachineTool.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_machine',
|
||||
description: 'Get a single machine by ID with all its details, including site and constructeurs.',
|
||||
)]
|
||||
class GetMachineTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $machineId): array
|
||||
{
|
||||
$machine = $this->machines->find($machineId);
|
||||
|
||||
if (!$machine) {
|
||||
$this->mcpError('not_found', "Machine not found: {$machineId}");
|
||||
}
|
||||
|
||||
$constructeurs = [];
|
||||
foreach ($machine->getConstructeurs() as $c) {
|
||||
$constructeurs[] = [
|
||||
'id' => $c->getId(),
|
||||
'name' => $c->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
$site = null;
|
||||
if ($machine->getSite()) {
|
||||
$site = [
|
||||
'id' => $machine->getSite()->getId(),
|
||||
'name' => $machine->getSite()->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $machine->getId(),
|
||||
'name' => $machine->getName(),
|
||||
'reference' => $machine->getReference(),
|
||||
'prix' => $machine->getPrix(),
|
||||
'site' => $site,
|
||||
'constructeurs' => $constructeurs,
|
||||
'createdAt' => $machine->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'updatedAt' => $machine->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
70
src/Mcp/Tool/Machine/ListMachineLinksTool.php
Normal file
70
src/Mcp/Tool/Machine/ListMachineLinksTool.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use App\Repository\MachineProductLinkRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_machine_links',
|
||||
description: 'List all links (component, piece, product) for a given machine, grouped by type.',
|
||||
)]
|
||||
class ListMachineLinksTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly MachineComponentLinkRepository $componentLinks,
|
||||
private readonly MachinePieceLinkRepository $pieceLinks,
|
||||
private readonly MachineProductLinkRepository $productLinks,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $machineId): array
|
||||
{
|
||||
$machine = $this->machines->find($machineId);
|
||||
if (null === $machine) {
|
||||
$this->mcpError('NotFound', "Machine {$machineId} not found.");
|
||||
}
|
||||
|
||||
$compRows = $this->componentLinks->createQueryBuilder('cl')
|
||||
->select('cl.id', 'IDENTITY(cl.composant) AS entityId', 'IDENTITY(cl.parentLink) AS parentLinkId', 'cl.nameOverride', 'cl.referenceOverride', 'cl.prixOverride')
|
||||
->where('cl.machine = :machine')
|
||||
->setParameter('machine', $machine)
|
||||
->orderBy('cl.id', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
$pieceRows = $this->pieceLinks->createQueryBuilder('pl')
|
||||
->select('pl.id', 'IDENTITY(pl.piece) AS entityId', 'IDENTITY(pl.parentLink) AS parentLinkId', 'pl.nameOverride', 'pl.referenceOverride', 'pl.prixOverride', 'pl.quantity')
|
||||
->where('pl.machine = :machine')
|
||||
->setParameter('machine', $machine)
|
||||
->orderBy('pl.id', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
$productRows = $this->productLinks->createQueryBuilder('prl')
|
||||
->select('prl.id', 'IDENTITY(prl.product) AS entityId', 'IDENTITY(prl.parentLink) AS parentLinkId', 'IDENTITY(prl.parentComponentLink) AS parentComponentLinkId', 'IDENTITY(prl.parentPieceLink) AS parentPieceLinkId')
|
||||
->where('prl.machine = :machine')
|
||||
->setParameter('machine', $machine)
|
||||
->orderBy('prl.id', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return $this->jsonResponse([
|
||||
'machineId' => $machineId,
|
||||
'componentLinks' => $compRows,
|
||||
'pieceLinks' => $pieceRows,
|
||||
'productLinks' => $productRows,
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
src/Mcp/Tool/Machine/ListMachinesTool.php
Normal file
55
src/Mcp/Tool/Machine/ListMachinesTool.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_machines',
|
||||
description: 'List machines with pagination. Filterable by name or reference.',
|
||||
)]
|
||||
class ListMachinesTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): array
|
||||
{
|
||||
$p = $this->paginationParams($page, $limit);
|
||||
|
||||
$countQb = $this->machines->createQueryBuilder('m')
|
||||
->select('COUNT(m.id)')
|
||||
;
|
||||
|
||||
$qb = $this->machines->createQueryBuilder('m')
|
||||
->select('m.id', 'm.name', 'm.reference', 'm.prix')
|
||||
->orderBy('m.name', 'ASC')
|
||||
;
|
||||
|
||||
if ('' !== $search) {
|
||||
$countQb->andWhere('LOWER(m.name) LIKE LOWER(:search) OR LOWER(m.reference) LIKE LOWER(:search)')
|
||||
->setParameter('search', "%{$search}%")
|
||||
;
|
||||
$qb->andWhere('LOWER(m.name) LIKE LOWER(:search) OR LOWER(m.reference) LIKE LOWER(:search)')
|
||||
->setParameter('search', "%{$search}%")
|
||||
;
|
||||
}
|
||||
|
||||
$total = (int) $countQb->getQuery()->getSingleScalarResult();
|
||||
|
||||
$items = $qb->setFirstResult($p['offset'])
|
||||
->setMaxResults($p['limit'])
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
|
||||
}
|
||||
}
|
||||
469
src/Mcp/Tool/Machine/MachineStructureTool.php
Normal file
469
src/Mcp/Tool/Machine/MachineStructureTool.php
Normal file
@@ -0,0 +1,469 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Machine;
|
||||
use App\Entity\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
use App\Entity\MachineProductLink;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\Product;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use App\Repository\MachineProductLinkRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_machine_structure',
|
||||
description: 'Get the full machine hierarchy: machine info, component links (with composant details, slots, overrides), piece links (with piece details, quantity, overrides), and product links.',
|
||||
)]
|
||||
class MachineStructureTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machineRepository,
|
||||
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
|
||||
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
|
||||
private readonly MachineProductLinkRepository $machineProductLinkRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $machineId): array
|
||||
{
|
||||
$machine = $this->machineRepository->find($machineId);
|
||||
|
||||
if (!$machine instanceof Machine) {
|
||||
$this->mcpError('not_found', "Machine not found: {$machineId}");
|
||||
}
|
||||
|
||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine]);
|
||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
|
||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
|
||||
|
||||
return $this->jsonResponse($this->normalizeStructureResponse(
|
||||
$machine,
|
||||
$componentLinks,
|
||||
$pieceLinks,
|
||||
$productLinks,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param MachineComponentLink[] $componentLinks
|
||||
* @param MachinePieceLink[] $pieceLinks
|
||||
* @param MachineProductLink[] $productLinks
|
||||
*/
|
||||
private function normalizeStructureResponse(
|
||||
Machine $machine,
|
||||
array $componentLinks,
|
||||
array $pieceLinks,
|
||||
array $productLinks,
|
||||
): array {
|
||||
$normalizedComponentLinks = $this->normalizeComponentLinks($componentLinks);
|
||||
$componentIndex = $this->indexById($normalizedComponentLinks);
|
||||
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
|
||||
|
||||
$childIds = [];
|
||||
foreach ($normalizedComponentLinks as $link) {
|
||||
$parentId = $link['parentComponentLinkId'] ?? null;
|
||||
if ($parentId && isset($componentIndex[$parentId])) {
|
||||
$componentIndex[$parentId]['childLinks'][] = $link;
|
||||
$childIds[$link['id']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
|
||||
|
||||
$rootComponents = array_filter(
|
||||
$componentIndex,
|
||||
static fn (array $link) => !isset($childIds[$link['id']]),
|
||||
);
|
||||
|
||||
return [
|
||||
'machine' => $this->normalizeMachine($machine),
|
||||
'componentLinks' => array_values($rootComponents),
|
||||
'pieceLinks' => $normalizedPieceLinks,
|
||||
'productLinks' => $this->normalizeProductLinks($productLinks),
|
||||
];
|
||||
}
|
||||
|
||||
private function attachPiecesToComponents(array &$componentIndex, array $pieceLinks): void
|
||||
{
|
||||
foreach ($pieceLinks as $pieceLink) {
|
||||
$parentId = $pieceLink['parentComponentLinkId'] ?? null;
|
||||
if ($parentId && isset($componentIndex[$parentId])) {
|
||||
$componentIndex[$parentId]['pieceLinks'][] = $pieceLink;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($componentIndex as &$component) {
|
||||
if (!empty($component['childLinks'])) {
|
||||
$this->attachPiecesToChildComponents($component['childLinks'], $pieceLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function attachPiecesToChildComponents(array &$childLinks, array $pieceLinks): void
|
||||
{
|
||||
foreach ($childLinks as &$child) {
|
||||
$childId = $child['id'] ?? null;
|
||||
if ($childId) {
|
||||
foreach ($pieceLinks as $pieceLink) {
|
||||
if (($pieceLink['parentComponentLinkId'] ?? null) === $childId) {
|
||||
$child['pieceLinks'][] = $pieceLink;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($child['childLinks'])) {
|
||||
$this->attachPiecesToChildComponents($child['childLinks'], $pieceLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeMachine(Machine $machine): array
|
||||
{
|
||||
$site = $machine->getSite();
|
||||
|
||||
return [
|
||||
'id' => $machine->getId(),
|
||||
'name' => $machine->getName(),
|
||||
'reference' => $machine->getReference(),
|
||||
'prix' => $machine->getPrix(),
|
||||
'siteId' => $site->getId(),
|
||||
'site' => [
|
||||
'id' => $site->getId(),
|
||||
'name' => $site->getName(),
|
||||
],
|
||||
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
|
||||
'customFields' => $this->normalizeCustomFields($machine->getCustomFields()),
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param MachineComponentLink[] $links
|
||||
*/
|
||||
private function normalizeComponentLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineComponentLink $link): array {
|
||||
$composant = $link->getComposant();
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'composantId' => $composant->getId(),
|
||||
'composant' => $this->normalizeComposant($composant),
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'childLinks' => [],
|
||||
'pieceLinks' => [],
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param MachinePieceLink[] $links
|
||||
*/
|
||||
private function normalizePieceLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachinePieceLink $link): array {
|
||||
$piece = $link->getPiece();
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'pieceId' => $piece->getId(),
|
||||
'piece' => $this->normalizePiece($piece),
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'quantity' => $this->resolvePieceQuantity($link),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
private function resolvePieceQuantity(MachinePieceLink $link): int
|
||||
{
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
if (!$parentLink) {
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
$composant = $parentLink->getComposant();
|
||||
$piece = $link->getPiece();
|
||||
|
||||
foreach ($composant->getPieceSlots() as $slot) {
|
||||
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
|
||||
return $slot->getQuantity();
|
||||
}
|
||||
}
|
||||
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param MachineProductLink[] $links
|
||||
*/
|
||||
private function normalizeProductLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineProductLink $link): array {
|
||||
$product = $link->getProduct();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'productId' => $product->getId(),
|
||||
'product' => $this->normalizeProduct($product),
|
||||
'parentLinkId' => $link->getParentLink()?->getId(),
|
||||
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
|
||||
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
private function normalizeComposant(Composant $composant): array
|
||||
{
|
||||
$type = $composant->getTypeComposant();
|
||||
|
||||
return [
|
||||
'id' => $composant->getId(),
|
||||
'name' => $composant->getName(),
|
||||
'reference' => $composant->getReference(),
|
||||
'prix' => $composant->getPrix(),
|
||||
'typeComposantId' => $type?->getId(),
|
||||
'typeComposant' => $this->normalizeModelType($type),
|
||||
'productId' => $composant->getProduct()?->getId(),
|
||||
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
|
||||
'structure' => $this->buildStructureFromSlots($composant),
|
||||
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [],
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($composant->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildStructureFromSlots(Composant $composant): array
|
||||
{
|
||||
$pieces = [];
|
||||
foreach ($composant->getPieceSlots() as $slot) {
|
||||
$pieceData = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
];
|
||||
if ($slot->getSelectedPiece()) {
|
||||
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece());
|
||||
}
|
||||
$pieces[] = $pieceData;
|
||||
}
|
||||
|
||||
$subcomponents = [];
|
||||
foreach ($composant->getSubcomponentSlots() as $slot) {
|
||||
$subcomponents[] = [
|
||||
'alias' => $slot->getAlias(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'typeComposantId' => $slot->getTypeComposant()?->getId(),
|
||||
'selectedComponentId' => $slot->getSelectedComposant()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
$products = [];
|
||||
foreach ($composant->getProductSlots() as $slot) {
|
||||
$products[] = [
|
||||
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'pieces' => $pieces,
|
||||
'subcomponents' => $subcomponents,
|
||||
'products' => $products,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePiece(Piece $piece): array
|
||||
{
|
||||
$type = $piece->getTypePiece();
|
||||
|
||||
return [
|
||||
'id' => $piece->getId(),
|
||||
'name' => $piece->getName(),
|
||||
'reference' => $piece->getReference(),
|
||||
'prix' => $piece->getPrix(),
|
||||
'typePieceId' => $type?->getId(),
|
||||
'typePiece' => $this->normalizeModelType($type),
|
||||
'productId' => $piece->getProduct()?->getId(),
|
||||
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
|
||||
'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()),
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getPieceCustomFields()) : [],
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($piece->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeProduct(Product $product): array
|
||||
{
|
||||
$type = $product->getTypeProduct();
|
||||
|
||||
return [
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'supplierPrice' => $product->getSupplierPrice(),
|
||||
'typeProductId' => $type?->getId(),
|
||||
'typeProduct' => $this->normalizeModelType($type),
|
||||
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getProductCustomFields()) : [],
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($product->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeModelType(?ModelType $type): ?array
|
||||
{
|
||||
if (!$type instanceof ModelType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $type->getId(),
|
||||
'name' => $type->getName(),
|
||||
'code' => $type->getCode(),
|
||||
'category' => $type->getCategory()->value,
|
||||
'structure' => $type->getStructure(),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeConstructeurs(Collection $constructeurs): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($constructeurs as $constructeur) {
|
||||
$items[] = [
|
||||
'id' => $constructeur->getId(),
|
||||
'name' => $constructeur->getName(),
|
||||
'email' => $constructeur->getEmail(),
|
||||
'phone' => $constructeur->getPhone(),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeCustomFields(Collection $customFields): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFields as $customField) {
|
||||
if (!$customField instanceof CustomField) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $customField->getId(),
|
||||
'name' => $customField->getName(),
|
||||
'type' => $customField->getType(),
|
||||
'required' => $customField->isRequired(),
|
||||
'options' => $customField->getOptions(),
|
||||
'defaultValue' => $customField->getDefaultValue(),
|
||||
'orderIndex' => $customField->getOrderIndex(),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeCustomFieldDefinitions(Collection $customFields): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFields as $cf) {
|
||||
if (!$cf instanceof CustomField) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeCustomFieldValues(Collection $customFieldValues): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFieldValues as $cfv) {
|
||||
if (!$cfv instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
$cf = $cfv->getCustomField();
|
||||
$items[] = [
|
||||
'id' => $cfv->getId(),
|
||||
'value' => $cfv->getValue(),
|
||||
'customField' => [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeOverrides(object $link): ?array
|
||||
{
|
||||
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;
|
||||
$reference = method_exists($link, 'getReferenceOverride') ? $link->getReferenceOverride() : null;
|
||||
$prix = method_exists($link, 'getPrixOverride') ? $link->getPrixOverride() : null;
|
||||
|
||||
if (null === $name && null === $reference && null === $prix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'reference' => $reference,
|
||||
'prix' => $prix,
|
||||
];
|
||||
}
|
||||
|
||||
private function indexById(array $links): array
|
||||
{
|
||||
$indexed = [];
|
||||
foreach ($links as $link) {
|
||||
if (is_array($link) && isset($link['id'])) {
|
||||
$indexed[$link['id']] = $link;
|
||||
}
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
}
|
||||
51
src/Mcp/Tool/Machine/RemoveMachineLinkTool.php
Normal file
51
src/Mcp/Tool/Machine/RemoveMachineLinkTool.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use App\Repository\MachineProductLinkRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'remove_machine_link',
|
||||
description: 'Remove a machine link by id and type (composant, piece, or product). Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class RemoveMachineLinkTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly MachineComponentLinkRepository $componentLinks,
|
||||
private readonly MachinePieceLinkRepository $pieceLinks,
|
||||
private readonly MachineProductLinkRepository $productLinks,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $linkId, string $linkType): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$link = match ($linkType) {
|
||||
'composant' => $this->componentLinks->find($linkId),
|
||||
'piece' => $this->pieceLinks->find($linkId),
|
||||
'product' => $this->productLinks->find($linkId),
|
||||
default => $this->mcpError('Validation', "Unknown link type '{$linkType}'. Expected composant, piece, or product."),
|
||||
};
|
||||
|
||||
if (null === $link) {
|
||||
$this->mcpError('NotFound', "Link {$linkId} of type {$linkType} not found.");
|
||||
}
|
||||
|
||||
$this->em->remove($link);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $linkId, 'type' => $linkType]);
|
||||
}
|
||||
}
|
||||
101
src/Mcp/Tool/Machine/UpdateMachineLinkTool.php
Normal file
101
src/Mcp/Tool/Machine/UpdateMachineLinkTool.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Entity\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'update_machine_link',
|
||||
description: 'Update overrides (nameOverride, referenceOverride, prixOverride) or quantity on an existing machine link. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class UpdateMachineLinkTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly MachineComponentLinkRepository $componentLinks,
|
||||
private readonly MachinePieceLinkRepository $pieceLinks,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $linkId,
|
||||
string $linkType,
|
||||
?string $nameOverride = null,
|
||||
?string $referenceOverride = null,
|
||||
?string $prixOverride = null,
|
||||
?int $quantity = null,
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
switch ($linkType) {
|
||||
case 'composant':
|
||||
$link = $this->componentLinks->find($linkId);
|
||||
if (null === $link) {
|
||||
$this->mcpError('NotFound', "MachineComponentLink {$linkId} not found.");
|
||||
}
|
||||
|
||||
$this->applyOverrides($link, $nameOverride, $referenceOverride, $prixOverride);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $link->getId(),
|
||||
'type' => 'composant',
|
||||
'nameOverride' => $link->getNameOverride(),
|
||||
'referenceOverride' => $link->getReferenceOverride(),
|
||||
'prixOverride' => $link->getPrixOverride(),
|
||||
]);
|
||||
|
||||
case 'piece':
|
||||
$link = $this->pieceLinks->find($linkId);
|
||||
if (null === $link) {
|
||||
$this->mcpError('NotFound', "MachinePieceLink {$linkId} not found.");
|
||||
}
|
||||
|
||||
$this->applyOverrides($link, $nameOverride, $referenceOverride, $prixOverride);
|
||||
if (null !== $quantity) {
|
||||
$link->setQuantity($quantity);
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $link->getId(),
|
||||
'type' => 'piece',
|
||||
'nameOverride' => $link->getNameOverride(),
|
||||
'referenceOverride' => $link->getReferenceOverride(),
|
||||
'prixOverride' => $link->getPrixOverride(),
|
||||
'quantity' => $link->getQuantity(),
|
||||
]);
|
||||
|
||||
case 'product':
|
||||
$this->mcpError('Validation', 'Product links do not have updatable overrides.');
|
||||
|
||||
// no break
|
||||
default:
|
||||
$this->mcpError('Validation', "Unknown link type '{$linkType}'. Expected composant, piece, or product.");
|
||||
}
|
||||
}
|
||||
|
||||
private function applyOverrides(MachineComponentLink|MachinePieceLink $link, ?string $nameOverride, ?string $referenceOverride, ?string $prixOverride): void
|
||||
{
|
||||
if (null !== $nameOverride) {
|
||||
$link->setNameOverride($nameOverride);
|
||||
}
|
||||
if (null !== $referenceOverride) {
|
||||
$link->setReferenceOverride($referenceOverride);
|
||||
}
|
||||
if (null !== $prixOverride) {
|
||||
$link->setPrixOverride($prixOverride);
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/Mcp/Tool/Machine/UpdateMachineTool.php
Normal file
85
src/Mcp/Tool/Machine/UpdateMachineTool.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\SiteRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'update_machine',
|
||||
description: 'Update an existing machine. Only provided fields are changed. prix must be a string. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class UpdateMachineTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly SiteRepository $sites,
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param null|string[] $constructeurIds
|
||||
*/
|
||||
public function __invoke(
|
||||
string $machineId,
|
||||
?string $name = null,
|
||||
?string $reference = null,
|
||||
?string $prix = null,
|
||||
?string $siteId = null,
|
||||
?array $constructeurIds = null,
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$machine = $this->machines->find($machineId);
|
||||
|
||||
if (!$machine) {
|
||||
$this->mcpError('not_found', "Machine not found: {$machineId}");
|
||||
}
|
||||
|
||||
if (null !== $name) {
|
||||
$machine->setName($name);
|
||||
}
|
||||
if (null !== $reference) {
|
||||
$machine->setReference($reference);
|
||||
}
|
||||
if (null !== $prix) {
|
||||
$machine->setPrix($prix);
|
||||
}
|
||||
|
||||
if (null !== $siteId) {
|
||||
$site = $this->sites->find($siteId);
|
||||
if (!$site) {
|
||||
$this->mcpError('not_found', "Site not found: {$siteId}");
|
||||
}
|
||||
$machine->setSite($site);
|
||||
}
|
||||
|
||||
if (null !== $constructeurIds) {
|
||||
foreach ($machine->getConstructeurs()->toArray() as $existing) {
|
||||
$machine->removeConstructeur($existing);
|
||||
}
|
||||
foreach ($constructeurIds as $cId) {
|
||||
$c = $this->constructeurs->find($cId);
|
||||
if (!$c) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$cId}");
|
||||
}
|
||||
$machine->addConstructeur($c);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['id' => $machine->getId(), 'name' => $machine->getName()]);
|
||||
}
|
||||
}
|
||||
58
src/Mcp/Tool/McpToolHelper.php
Normal file
58
src/Mcp/Tool/McpToolHelper.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool;
|
||||
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use RuntimeException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
trait McpToolHelper
|
||||
{
|
||||
private function requireRole(Security $security, string $role): void
|
||||
{
|
||||
if (!$security->isGranted($role)) {
|
||||
throw new RuntimeException("Permission denied: {$role} required.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{TextContent}
|
||||
*/
|
||||
private function jsonResponse(array $data): array
|
||||
{
|
||||
return [new TextContent(text: json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE))];
|
||||
}
|
||||
|
||||
private function mcpError(string $category, string $message): never
|
||||
{
|
||||
throw new RuntimeException("{$category}: {$message}");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{page: int, limit: int, offset: int}
|
||||
*/
|
||||
private function paginationParams(int $page = 1, int $limit = 30): array
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$limit = min(100, max(1, $limit));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
return ['page' => $page, 'limit' => $limit, 'offset' => $offset];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{TextContent}
|
||||
*/
|
||||
private function paginatedResponse(array $items, int $total, int $page, int $limit): array
|
||||
{
|
||||
return $this->jsonResponse([
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'pageCount' => (int) ceil($total / max(1, $limit)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
57
src/Mcp/Tool/ModelType/CreateModelTypeTool.php
Normal file
57
src/Mcp/Tool/ModelType/CreateModelTypeTool.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\ModelType;
|
||||
|
||||
use App\Entity\ModelType;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'create_model_type',
|
||||
description: 'Create a new model type. Category must be one of: composant, piece, product. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class CreateModelTypeTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $name, string $category, string $code = ''): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$enumCategory = match (strtolower($category)) {
|
||||
'composant', 'component' => ModelCategory::COMPONENT,
|
||||
'piece' => ModelCategory::PIECE,
|
||||
'product' => ModelCategory::PRODUCT,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (null === $enumCategory) {
|
||||
$this->mcpError('validation', "Invalid category '{$category}'. Must be one of: composant, piece, product.");
|
||||
}
|
||||
|
||||
$mt = new ModelType();
|
||||
$mt->setName($name);
|
||||
$mt->setCategory($enumCategory);
|
||||
$mt->setCode('' !== $code ? $code : strtoupper(substr(str_replace(' ', '-', $name), 0, 20)).'-'.bin2hex(random_bytes(3)));
|
||||
|
||||
$this->em->persist($mt);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $mt->getId(),
|
||||
'name' => $mt->getName(),
|
||||
'code' => $mt->getCode(),
|
||||
'category' => $mt->getCategory()->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
src/Mcp/Tool/ModelType/DeleteModelTypeTool.php
Normal file
42
src/Mcp/Tool/ModelType/DeleteModelTypeTool.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\ModelType;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'delete_model_type',
|
||||
description: 'Delete a model type by ID. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class DeleteModelTypeTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $modelTypeId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$mt = $this->modelTypes->find($modelTypeId);
|
||||
|
||||
if (!$mt) {
|
||||
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
|
||||
}
|
||||
|
||||
$this->em->remove($mt);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $modelTypeId]);
|
||||
}
|
||||
}
|
||||
78
src/Mcp/Tool/ModelType/GetModelTypeTool.php
Normal file
78
src/Mcp/Tool/ModelType/GetModelTypeTool.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\ModelType;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_model_type',
|
||||
description: 'Get a single model type by ID with full details including skeleton requirements.',
|
||||
)]
|
||||
class GetModelTypeTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $modelTypeId): array
|
||||
{
|
||||
$mt = $this->modelTypes->find($modelTypeId);
|
||||
|
||||
if (!$mt) {
|
||||
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
|
||||
}
|
||||
|
||||
$skeletonPieces = [];
|
||||
foreach ($mt->getSkeletonPieceRequirements() as $req) {
|
||||
$skeletonPieces[] = [
|
||||
'id' => $req->getId(),
|
||||
'typePieceId' => $req->getTypePiece()->getId(),
|
||||
'typePiece' => $req->getTypePiece()->getName(),
|
||||
'position' => $req->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$skeletonProducts = [];
|
||||
foreach ($mt->getSkeletonProductRequirements() as $req) {
|
||||
$skeletonProducts[] = [
|
||||
'id' => $req->getId(),
|
||||
'typeProductId' => $req->getTypeProduct()->getId(),
|
||||
'typeProduct' => $req->getTypeProduct()->getName(),
|
||||
'familyCode' => $req->getFamilyCode(),
|
||||
'position' => $req->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$skeletonSubcomponents = [];
|
||||
foreach ($mt->getSkeletonSubcomponentRequirements() as $req) {
|
||||
$skeletonSubcomponents[] = [
|
||||
'id' => $req->getId(),
|
||||
'alias' => $req->getAlias(),
|
||||
'familyCode' => $req->getFamilyCode(),
|
||||
'typeComposantId' => $req->getTypeComposant()?->getId(),
|
||||
'typeComposant' => $req->getTypeComposant()?->getName(),
|
||||
'position' => $req->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $mt->getId(),
|
||||
'name' => $mt->getName(),
|
||||
'code' => $mt->getCode(),
|
||||
'category' => $mt->getCategory()->value,
|
||||
'notes' => $mt->getNotes(),
|
||||
'description' => $mt->getDescription(),
|
||||
'skeletonPieceRequirements' => $skeletonPieces,
|
||||
'skeletonProductRequirements' => $skeletonProducts,
|
||||
'skeletonSubcomponentRequirements' => $skeletonSubcomponents,
|
||||
'createdAt' => $mt->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'updatedAt' => $mt->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user