Compare commits
37 Commits
feat/json-
...
172ec78c5f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
172ec78c5f | ||
|
|
d70b9086d5 | ||
|
|
73ebd6902d | ||
|
|
ded1f7a8b6 | ||
|
|
3b35598b07 | ||
|
|
06ce9fb1f2 | ||
|
|
8851f22e4e | ||
|
|
330b9376f6 | ||
|
|
4468fd7cdf | ||
|
|
509c4d2247 | ||
|
|
043f6b1ce6 | ||
|
|
d5a43fc9bb | ||
|
|
0de2aba538 | ||
|
|
5ec6e49af2 | ||
|
|
8d920d5f65 | ||
|
|
342b0afdbb | ||
|
|
2043e5b643 | ||
|
|
21e5ad5381 | ||
|
|
53b6abc9a8 | ||
|
|
826dae7712 | ||
|
|
38777b7de0 | ||
|
|
add3a9a21f | ||
|
|
f965affc94 | ||
|
|
4340a0e13e | ||
|
|
bd7259ed05 | ||
|
|
2f173e766d | ||
|
|
4f1e136dc5 | ||
|
|
e335f4c24c | ||
|
|
46ea3ca8ad | ||
|
|
65fbd38b55 | ||
|
|
37aa755819 | ||
|
|
98caaa148d | ||
|
|
523eed927e | ||
|
|
43bec07bb8 | ||
|
|
0181f18778 | ||
|
|
8e0acf4896 | ||
|
|
aa8e043c83 |
12
.mcp.json
Normal file
12
.mcp.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"inventory": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "http://inventory.malio-dev.fr/_mcp",
|
||||||
|
"headers": {
|
||||||
|
"X-Profile-Id": "admin-default-profile",
|
||||||
|
"X-Profile-Password": "A123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Submodule Inventory_frontend updated: 271844efb1...d0dc01deb1
@@ -14,6 +14,7 @@
|
|||||||
"doctrine/orm": "^3.6",
|
"doctrine/orm": "^3.6",
|
||||||
"lexik/jwt-authentication-bundle": "^3.2",
|
"lexik/jwt-authentication-bundle": "^3.2",
|
||||||
"nelmio/cors-bundle": "^2.6",
|
"nelmio/cors-bundle": "^2.6",
|
||||||
|
"nyholm/psr7": "^1.8",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6",
|
"phpdocumentor/reflection-docblock": "^5.6",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
"symfony/asset": "8.0.*",
|
"symfony/asset": "8.0.*",
|
||||||
@@ -22,8 +23,10 @@
|
|||||||
"symfony/expression-language": "8.0.*",
|
"symfony/expression-language": "8.0.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
|
"symfony/mcp-bundle": "^0.6.0",
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "8.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
|
"symfony/rate-limiter": "8.0.*",
|
||||||
"symfony/runtime": "8.0.*",
|
"symfony/runtime": "8.0.*",
|
||||||
"symfony/security-bundle": "8.0.*",
|
"symfony/security-bundle": "8.0.*",
|
||||||
"symfony/serializer": "8.0.*",
|
"symfony/serializer": "8.0.*",
|
||||||
|
|||||||
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 Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||||
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||||
|
use Symfony\AI\McpBundle\McpBundle;
|
||||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||||
@@ -22,4 +23,5 @@ return [
|
|||||||
ApiPlatformBundle::class => ['all' => true],
|
ApiPlatformBundle::class => ['all' => true],
|
||||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||||
DAMADoctrineTestBundle::class => ['test' => true],
|
DAMADoctrineTestBundle::class => ['test' => true],
|
||||||
|
McpBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
api_platform:
|
api_platform:
|
||||||
title: Inventory API
|
title: Inventory API
|
||||||
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
||||||
version: 1.8.1
|
version: 1.9.1
|
||||||
defaults:
|
defaults:
|
||||||
stateless: false
|
stateless: false
|
||||||
cache_headers:
|
cache_headers:
|
||||||
|
|||||||
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
|
||||||
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'
|
||||||
@@ -27,6 +27,12 @@ security:
|
|||||||
pattern: ^/api/session/profiles?$
|
pattern: ^/api/session/profiles?$
|
||||||
security: false
|
security: false
|
||||||
|
|
||||||
|
mcp:
|
||||||
|
pattern: ^/_mcp
|
||||||
|
stateless: true
|
||||||
|
custom_authenticators:
|
||||||
|
- App\Mcp\Security\McpHeaderAuthenticator
|
||||||
|
|
||||||
api:
|
api:
|
||||||
pattern: ^/api
|
pattern: ^/api
|
||||||
stateless: false
|
stateless: false
|
||||||
@@ -49,6 +55,7 @@ security:
|
|||||||
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
||||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
||||||
|
- { path: ^/_mcp, roles: ROLE_USER }
|
||||||
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
|
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
|
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
|
||||||
|
|
||||||
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||||
@@ -624,7 +622,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* }>,
|
* }>,
|
||||||
* },
|
* },
|
||||||
* rate_limiter?: bool|array{ // Rate limiter configuration
|
* rate_limiter?: bool|array{ // Rate limiter configuration
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: true
|
||||||
* limiters?: array<string, array{ // Default: []
|
* limiters?: array<string, array{ // Default: []
|
||||||
* lock_factory?: scalar|null|Param, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
* lock_factory?: scalar|null|Param, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
|
||||||
* cache_pool?: scalar|null|Param, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
* cache_pool?: scalar|null|Param, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
|
||||||
@@ -1387,7 +1385,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* mercure?: bool|array{
|
* mercure?: bool|array{
|
||||||
* enabled?: bool|Param, // Default: false
|
* 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
|
* 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 @var in updates (including delete ones). // Default: false
|
* include_type?: bool|Param, // Always include @type in updates (including delete ones). // Default: false
|
||||||
* },
|
* },
|
||||||
* messenger?: bool|array{
|
* messenger?: bool|array{
|
||||||
* enabled?: bool|Param, // Default: false
|
* enabled?: bool|Param, // Default: false
|
||||||
@@ -1614,6 +1612,37 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* enable_static_query_cache?: bool|Param, // Default: true
|
* enable_static_query_cache?: bool|Param, // Default: true
|
||||||
* connection_keys?: list<mixed>,
|
* connection_keys?: list<mixed>,
|
||||||
* }
|
* }
|
||||||
|
* @psalm-type McpConfig = array{
|
||||||
|
* app?: scalar|null|Param, // Default: "app"
|
||||||
|
* version?: scalar|null|Param, // Default: "0.0.1"
|
||||||
|
* description?: scalar|null|Param, // Default: null
|
||||||
|
* icons?: list<array{ // Default: []
|
||||||
|
* src: scalar|null|Param,
|
||||||
|
* mime_type?: scalar|null|Param, // Default: null
|
||||||
|
* sizes?: list<scalar|null|Param>,
|
||||||
|
* }>,
|
||||||
|
* website_url?: scalar|null|Param, // Default: null
|
||||||
|
* pagination_limit?: int|Param, // Default: 50
|
||||||
|
* instructions?: scalar|null|Param, // Default: null
|
||||||
|
* client_transports?: array{
|
||||||
|
* stdio?: bool|Param, // Default: false
|
||||||
|
* http?: bool|Param, // Default: false
|
||||||
|
* },
|
||||||
|
* discovery?: array{
|
||||||
|
* scan_dirs?: list<scalar|null|Param>,
|
||||||
|
* exclude_dirs?: list<scalar|null|Param>,
|
||||||
|
* },
|
||||||
|
* http?: array{
|
||||||
|
* path?: scalar|null|Param, // Default: "/_mcp"
|
||||||
|
* session?: array{
|
||||||
|
* store?: "file"|"memory"|"cache"|Param, // Default: "file"
|
||||||
|
* directory?: scalar|null|Param, // Default: "%kernel.cache_dir%/mcp-sessions"
|
||||||
|
* cache_pool?: scalar|null|Param, // Default: "cache.mcp.sessions"
|
||||||
|
* prefix?: scalar|null|Param, // Default: "mcp-"
|
||||||
|
* ttl?: int|Param, // Default: 3600
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* }
|
||||||
* @psalm-type ConfigType = array{
|
* @psalm-type ConfigType = array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1626,6 +1655,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
|
* mcp?: McpConfig,
|
||||||
* "when@dev"?: array{
|
* "when@dev"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1638,6 +1668,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
|
* mcp?: McpConfig,
|
||||||
* },
|
* },
|
||||||
* "when@prod"?: array{
|
* "when@prod"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1651,6 +1682,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* nelmio_cors?: NelmioCorsConfig,
|
* nelmio_cors?: NelmioCorsConfig,
|
||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
|
* mcp?: McpConfig,
|
||||||
* },
|
* },
|
||||||
* "when@test"?: array{
|
* "when@test"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1665,6 +1697,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* api_platform?: ApiPlatformConfig,
|
* api_platform?: ApiPlatformConfig,
|
||||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||||
* dama_doctrine_test?: DamaDoctrineTestConfig,
|
* dama_doctrine_test?: DamaDoctrineTestConfig,
|
||||||
|
* mcp?: McpConfig,
|
||||||
* },
|
* },
|
||||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
|
|||||||
@@ -12,3 +12,7 @@ api_login_check:
|
|||||||
|
|
||||||
controllers:
|
controllers:
|
||||||
resource: routing.controllers
|
resource: routing.controllers
|
||||||
|
|
||||||
|
mcp:
|
||||||
|
resource: .
|
||||||
|
type: mcp
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ services:
|
|||||||
tags:
|
tags:
|
||||||
- { name: doctrine.event_subscriber }
|
- { name: doctrine.event_subscriber }
|
||||||
|
|
||||||
|
App\Mcp\Security\McpHeaderAuthenticator:
|
||||||
|
arguments:
|
||||||
|
$mcpAuthLimiter: '@limiter.mcp_auth'
|
||||||
|
|
||||||
App\OpenApi\OpenApiDecorator:
|
App\OpenApi\OpenApiDecorator:
|
||||||
decorates: 'api_platform.openapi.factory'
|
decorates: 'api_platform.openapi.factory'
|
||||||
arguments:
|
arguments:
|
||||||
|
|||||||
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 |
|
||||||
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
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.
|
||||||
91
migrations/Version20260323100000.php
Normal file
91
migrations/Version20260323100000.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scaffold missing composant slots for existing composants that have
|
||||||
|
* a typeComposant with skeleton requirements but no corresponding slots.
|
||||||
|
*/
|
||||||
|
final class Version20260323100000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Scaffold missing composant slots from skeleton requirements for existing composants';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Piece slots
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO composant_piece_slots (id, "composantid", "typepieceid", quantity, position, "createdat", "updatedat")
|
||||||
|
SELECT
|
||||||
|
'cl' || substr(md5(random()::text || clock_timestamp()::text || spr.id), 1, 24),
|
||||||
|
c.id,
|
||||||
|
spr."typepieceid",
|
||||||
|
1,
|
||||||
|
spr.position,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
FROM composants c
|
||||||
|
JOIN skeleton_piece_requirements spr ON spr."modeltypeid" = c."typecomposantid"
|
||||||
|
WHERE c."typecomposantid" IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM composant_piece_slots cps
|
||||||
|
WHERE cps."composantid" = c.id AND cps."typepieceid" = spr."typepieceid"
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// Product slots
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO composant_product_slots (id, "composantid", "typeproductid", "familycode", position, "createdat", "updatedat")
|
||||||
|
SELECT
|
||||||
|
'cl' || substr(md5(random()::text || clock_timestamp()::text || spr.id), 1, 24),
|
||||||
|
c.id,
|
||||||
|
spr."typeproductid",
|
||||||
|
spr."familycode",
|
||||||
|
spr.position,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
FROM composants c
|
||||||
|
JOIN skeleton_product_requirements spr ON spr."modeltypeid" = c."typecomposantid"
|
||||||
|
WHERE c."typecomposantid" IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM composant_product_slots cps
|
||||||
|
WHERE cps."composantid" = c.id AND cps."typeproductid" = spr."typeproductid"
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// Subcomponent slots
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO composant_subcomponent_slots (id, "composantid", alias, "familycode", "typecomposantid", position, "createdat", "updatedat")
|
||||||
|
SELECT
|
||||||
|
'cl' || substr(md5(random()::text || clock_timestamp()::text || spr.id), 1, 24),
|
||||||
|
c.id,
|
||||||
|
spr.alias,
|
||||||
|
spr."familycode",
|
||||||
|
spr."typecomposantid",
|
||||||
|
spr.position,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
FROM composants c
|
||||||
|
JOIN skeleton_subcomponent_requirements spr ON spr."modeltypeid" = c."typecomposantid"
|
||||||
|
WHERE c."typecomposantid" IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM composant_subcomponent_slots css
|
||||||
|
WHERE css."composantid" = c.id
|
||||||
|
AND COALESCE(css."typecomposantid", '') = COALESCE(spr."typecomposantid", '')
|
||||||
|
AND COALESCE(css.alias, '') = COALESCE(spr.alias, '')
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// No-op: slots created by this migration are valid data
|
||||||
|
}
|
||||||
|
}
|
||||||
31
migrations/Version20260323141052.php
Normal file
31
migrations/Version20260323141052.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20260323141052 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add type column to documents table and classify existing documents by mimeType';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql("DO \$\$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'documents' AND column_name = 'type') THEN ALTER TABLE documents ADD COLUMN type VARCHAR(20) NOT NULL DEFAULT 'documentation'; END IF; END \$\$");
|
||||||
|
$this->addSql("UPDATE documents SET type = 'photo' WHERE mimetype LIKE 'image/%'");
|
||||||
|
$this->addSql("UPDATE documents SET type = 'autre' WHERE type = 'documentation' AND mimetype NOT LIKE 'application/pdf' AND mimetype NOT LIKE 'image/%'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE documents DROP COLUMN IF EXISTS type');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
migrations/Version20260323160000.php
Normal file
30
migrations/Version20260323160000.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260323160000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add comment_id FK on documents table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql("DO \$\$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'documents' AND column_name = 'comment_id') THEN ALTER TABLE documents ADD COLUMN comment_id VARCHAR(36) DEFAULT NULL; END IF; END \$\$");
|
||||||
|
$this->addSql("DO \$\$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_documents_comment') THEN ALTER TABLE documents ADD CONSTRAINT fk_documents_comment FOREIGN KEY (comment_id) REFERENCES comments(id) ON DELETE CASCADE; END IF; END \$\$");
|
||||||
|
$this->addSql('CREATE INDEX IF NOT EXISTS idx_documents_comment_id ON documents(comment_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_comment');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS idx_documents_comment_id');
|
||||||
|
$this->addSql('ALTER TABLE documents DROP COLUMN IF EXISTS comment_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
46
scripts/check-prod-audit-dates.php
Normal file
46
scripts/check-prod-audit-dates.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use Doctrine\DBAL\DriverManager;
|
||||||
|
|
||||||
|
$conn = DriverManager::getConnection([
|
||||||
|
'driver' => 'pdo_pgsql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => 'inventory',
|
||||||
|
'user' => 'ferme_user',
|
||||||
|
'password' => 'fermerecette',
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo "--- Audit logs with customField deletions (to:null) ---\n";
|
||||||
|
$rows = $conn->fetchAllAssociative("
|
||||||
|
SELECT al.entityid, al.entitytype, al.diff::text as diff, al.createdat
|
||||||
|
FROM audit_logs al
|
||||||
|
WHERE al.diff::text LIKE '%customField%'
|
||||||
|
AND al.diff::text LIKE '%\"to\":null%'
|
||||||
|
ORDER BY al.createdat DESC
|
||||||
|
LIMIT 20
|
||||||
|
");
|
||||||
|
echo sprintf("Found %d entries\n\n", count($rows));
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
echo sprintf("[%s] %s %s: %s\n", $r['createdat'], $r['entitytype'], $r['entityid'], substr($r['diff'], 0, 120));
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n--- Orphaned CFValues (pointing to CFs with no ModelType) ---\n";
|
||||||
|
$rows = $conn->fetchAllAssociative("
|
||||||
|
SELECT COUNT(*) as cnt,
|
||||||
|
CASE WHEN cfv.pieceid IS NOT NULL THEN 'piece'
|
||||||
|
WHEN cfv.composantid IS NOT NULL THEN 'composant'
|
||||||
|
WHEN cfv.productid IS NOT NULL THEN 'product'
|
||||||
|
ELSE 'unknown' END as entity_type
|
||||||
|
FROM custom_field_values cfv
|
||||||
|
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||||
|
WHERE cf.typecomposantid IS NULL AND cf.typepieceid IS NULL AND cf.typeproductid IS NULL
|
||||||
|
GROUP BY entity_type
|
||||||
|
");
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
echo sprintf(" %s: %d orphaned values\n", $r['entity_type'], $r['cnt']);
|
||||||
|
}
|
||||||
54
scripts/check-prod-missing-piece-cfs.php
Normal file
54
scripts/check-prod-missing-piece-cfs.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use Doctrine\DBAL\DriverManager;
|
||||||
|
|
||||||
|
$conn = DriverManager::getConnection([
|
||||||
|
'driver' => 'pdo_pgsql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => 'inventory',
|
||||||
|
'user' => 'ferme_user',
|
||||||
|
'password' => 'fermerecette',
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo "--- ModelTypes with orphaned piece values (CFs lost) ---\n\n";
|
||||||
|
$rows = $conn->fetchAllAssociative("
|
||||||
|
SELECT mt.id, mt.name, mt.category,
|
||||||
|
cf_orphan.name as lost_field,
|
||||||
|
COUNT(*) as affected_pieces,
|
||||||
|
COUNT(*) FILTER (WHERE cfv.value != '' AND cfv.value IS NOT NULL) as with_data
|
||||||
|
FROM custom_field_values cfv
|
||||||
|
JOIN custom_fields cf_orphan ON cf_orphan.id = cfv.customfieldid
|
||||||
|
JOIN pieces p ON p.id = cfv.pieceid
|
||||||
|
JOIN model_types mt ON mt.id = p.typepieceid
|
||||||
|
WHERE cf_orphan.typecomposantid IS NULL
|
||||||
|
AND cf_orphan.typepieceid IS NULL
|
||||||
|
AND cf_orphan.typeproductid IS NULL
|
||||||
|
GROUP BY mt.id, mt.name, mt.category, cf_orphan.name
|
||||||
|
ORDER BY mt.name, cf_orphan.name
|
||||||
|
");
|
||||||
|
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$status = $r['with_data'] > 0 ? 'HAS DATA' : 'empty';
|
||||||
|
echo sprintf(" ModelType '%s' | field '%s' | %d pieces (%d with data) [%s]\n",
|
||||||
|
$r['name'], $r['lost_field'], $r['affected_pieces'], $r['with_data'], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf("\nTotal: %d ModelType/field combinations\n", count($rows));
|
||||||
|
|
||||||
|
// Check if these fields exist on the current ModelType
|
||||||
|
echo "\n--- Current CFs on these ModelTypes ---\n\n";
|
||||||
|
$mtIds = array_unique(array_column($rows, 'id'));
|
||||||
|
foreach ($mtIds as $mtId) {
|
||||||
|
$mtName = $conn->fetchOne("SELECT name FROM model_types WHERE id = ?", [$mtId]);
|
||||||
|
$currentCfs = $conn->fetchAllAssociative(
|
||||||
|
"SELECT name FROM custom_fields WHERE typepieceid = ? ORDER BY orderindex",
|
||||||
|
[$mtId]
|
||||||
|
);
|
||||||
|
$cfNames = array_column($currentCfs, 'name');
|
||||||
|
echo sprintf(" '%s': %s\n", $mtName, $cfNames ? implode(', ', $cfNames) : '(aucun CF)');
|
||||||
|
}
|
||||||
83
scripts/check-prod-orphaned-detail.php
Normal file
83
scripts/check-prod-orphaned-detail.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use Doctrine\DBAL\DriverManager;
|
||||||
|
|
||||||
|
$conn = DriverManager::getConnection([
|
||||||
|
'driver' => 'pdo_pgsql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => 'inventory',
|
||||||
|
'user' => 'ferme_user',
|
||||||
|
'password' => 'fermerecette',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Show a sample of orphaned CFValues for pieces
|
||||||
|
echo "--- Sample orphaned piece CFValues ---\n";
|
||||||
|
$rows = $conn->fetchAllAssociative("
|
||||||
|
SELECT cfv.id as cfv_id, cfv.value, cfv.pieceid,
|
||||||
|
cf.id as cf_id, cf.name as cf_name,
|
||||||
|
cf.typecomposantid, cf.typepieceid, cf.typeproductid,
|
||||||
|
p.name as piece_name, p.typepieceid as piece_modeltype
|
||||||
|
FROM custom_field_values cfv
|
||||||
|
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||||
|
JOIN pieces p ON p.id = cfv.pieceid
|
||||||
|
WHERE cfv.pieceid IS NOT NULL
|
||||||
|
AND cf.typepieceid IS NULL
|
||||||
|
ORDER BY p.name
|
||||||
|
LIMIT 10
|
||||||
|
");
|
||||||
|
echo sprintf("Found %d (limited to 10)\n\n", count($rows));
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
echo sprintf(" Piece '%s' | field '%s' = '%s' | CF FK: composant=%s piece=%s product=%s\n",
|
||||||
|
$r['piece_name'], $r['cf_name'], $r['value'],
|
||||||
|
$r['typecomposantid'] ?? 'NULL',
|
||||||
|
$r['typepieceid'] ?? 'NULL',
|
||||||
|
$r['typeproductid'] ?? 'NULL'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a sample of orphaned CFValues for composants
|
||||||
|
echo "\n--- Sample orphaned composant CFValues ---\n";
|
||||||
|
$rows = $conn->fetchAllAssociative("
|
||||||
|
SELECT cfv.id as cfv_id, cfv.value, cfv.composantid,
|
||||||
|
cf.id as cf_id, cf.name as cf_name,
|
||||||
|
cf.typecomposantid, cf.typepieceid, cf.typeproductid,
|
||||||
|
c.name as composant_name, c.typecomposantid as composant_modeltype
|
||||||
|
FROM custom_field_values cfv
|
||||||
|
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||||
|
JOIN composants c ON c.id = cfv.composantid
|
||||||
|
WHERE cfv.composantid IS NOT NULL
|
||||||
|
AND cf.typecomposantid IS NULL
|
||||||
|
ORDER BY c.name
|
||||||
|
LIMIT 10
|
||||||
|
");
|
||||||
|
echo sprintf("Found %d (limited to 10)\n\n", count($rows));
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
echo sprintf(" Composant '%s' | field '%s' = '%s' | CF FK: composant=%s piece=%s product=%s\n",
|
||||||
|
$r['composant_name'], $r['cf_name'], $r['value'],
|
||||||
|
$r['typecomposantid'] ?? 'NULL',
|
||||||
|
$r['typepieceid'] ?? 'NULL',
|
||||||
|
$r['typeproductid'] ?? 'NULL'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check: are there CFs with ONLY typepieceid NULL but other FKs set?
|
||||||
|
echo "\n--- Orphaned CF FK patterns ---\n";
|
||||||
|
$rows = $conn->fetchAllAssociative("
|
||||||
|
SELECT
|
||||||
|
CASE WHEN typecomposantid IS NULL THEN 'NULL' ELSE 'SET' END as composant_fk,
|
||||||
|
CASE WHEN typepieceid IS NULL THEN 'NULL' ELSE 'SET' END as piece_fk,
|
||||||
|
CASE WHEN typeproductid IS NULL THEN 'NULL' ELSE 'SET' END as product_fk,
|
||||||
|
COUNT(*) as cnt
|
||||||
|
FROM custom_fields
|
||||||
|
GROUP BY composant_fk, piece_fk, product_fk
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
");
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
echo sprintf(" composant=%s piece=%s product=%s : %d CFs\n",
|
||||||
|
$r['composant_fk'], $r['piece_fk'], $r['product_fk'], $r['cnt']);
|
||||||
|
}
|
||||||
36
scripts/check-prod-values.php
Normal file
36
scripts/check-prod-values.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use Doctrine\DBAL\DriverManager;
|
||||||
|
|
||||||
|
$conn = DriverManager::getConnection([
|
||||||
|
'driver' => 'pdo_pgsql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => 'inventory',
|
||||||
|
'user' => 'ferme_user',
|
||||||
|
'password' => 'fermerecette',
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo "--- Piece 'Arbre du palier pied E1' ---\n";
|
||||||
|
$rows = $conn->fetchAllAssociative("SELECT p.name, cfv.value, cf.name as field_name FROM pieces p JOIN custom_field_values cfv ON cfv.pieceid = p.id JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE p.id = 'cl3d978dd4b071daff8fb185f7' ORDER BY cf.orderindex");
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
echo sprintf(" %s: '%s'\n", $r['field_name'], $r['value']);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n--- Composant 'Cage écureuil pied E8' ---\n";
|
||||||
|
$rows = $conn->fetchAllAssociative("SELECT c.name, cfv.value, cf.name as field_name FROM composants c JOIN custom_field_values cfv ON cfv.composantid = c.id JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE c.id = 'cl5b5e336095de8d4ece81b2dc' ORDER BY cf.orderindex");
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
echo sprintf(" %s: '%s'\n", $r['field_name'], $r['value']);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n--- Count empty piece values (ModelType Arbre) ---\n";
|
||||||
|
$count = $conn->fetchOne("SELECT COUNT(*) FROM pieces p JOIN custom_field_values cfv ON cfv.pieceid = p.id WHERE p.typepieceid = 'cmgujpyjf002q4705j6hv1nkk' AND (cfv.value = '' OR cfv.value IS NULL)");
|
||||||
|
echo sprintf(" Empty values: %d\n", $count);
|
||||||
|
|
||||||
|
echo "\n--- Count orphaned CustomField definitions ---\n";
|
||||||
|
$count = $conn->fetchOne('SELECT COUNT(*) FROM custom_fields WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL');
|
||||||
|
echo sprintf(" Orphaned CFs: %d\n", $count);
|
||||||
199
scripts/fix-prod-all.php
Normal file
199
scripts/fix-prod-all.php
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined fix script for prod:
|
||||||
|
* 1. Migrate orphaned CFValues to current CFs (by name match)
|
||||||
|
* 2. Restore deleted composant values from audit logs
|
||||||
|
* 3. Clean up orphaned CF definitions
|
||||||
|
*
|
||||||
|
* Usage: php scripts/fix-prod-all.php [--dry-run]
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use Doctrine\DBAL\DriverManager;
|
||||||
|
|
||||||
|
$dryRun = in_array('--dry-run', $argv, true);
|
||||||
|
|
||||||
|
$conn = DriverManager::getConnection([
|
||||||
|
'driver' => 'pdo_pgsql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => 'inventory',
|
||||||
|
'user' => 'ferme_user',
|
||||||
|
'password' => 'fermerecette',
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n";
|
||||||
|
|
||||||
|
$migratedCount = 0;
|
||||||
|
$restoredCount = 0;
|
||||||
|
$deletedOrphanedCfv = 0;
|
||||||
|
$deletedOrphanedCf = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PART 1: Migrate orphaned CFValues to current CFs
|
||||||
|
// ============================================================
|
||||||
|
echo "--- PART 1: Migrate orphaned CFValues ---\n\n";
|
||||||
|
|
||||||
|
$entityTypes = [
|
||||||
|
['label' => 'piece', 'entityTable' => 'pieces', 'cfvFk' => 'pieceid', 'modelTypeFk' => 'typepieceid', 'cfModelTypeFk' => 'typepieceid'],
|
||||||
|
['label' => 'composant', 'entityTable' => 'composants', 'cfvFk' => 'composantid', 'modelTypeFk' => 'typecomposantid', 'cfModelTypeFk' => 'typecomposantid'],
|
||||||
|
['label' => 'product', 'entityTable' => 'products', 'cfvFk' => 'productid', 'modelTypeFk' => 'typeproductid', 'cfModelTypeFk' => 'typeproductid'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($entityTypes as $et) {
|
||||||
|
// Find orphaned CFValues: the CF has ALL 3 FKs NULL
|
||||||
|
$orphanedValues = $conn->fetchAllAssociative("
|
||||||
|
SELECT cfv.id as cfv_id, cfv.value, cfv.{$et['cfvFk']} as entity_id,
|
||||||
|
cf_old.id as old_cf_id, cf_old.name as field_name,
|
||||||
|
e.name as entity_name, e.{$et['modelTypeFk']} as model_type_id
|
||||||
|
FROM custom_field_values cfv
|
||||||
|
JOIN custom_fields cf_old ON cf_old.id = cfv.customfieldid
|
||||||
|
JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']}
|
||||||
|
WHERE cfv.{$et['cfvFk']} IS NOT NULL
|
||||||
|
AND cf_old.typecomposantid IS NULL
|
||||||
|
AND cf_old.typepieceid IS NULL
|
||||||
|
AND cf_old.typeproductid IS NULL
|
||||||
|
ORDER BY e.name, cf_old.name
|
||||||
|
");
|
||||||
|
|
||||||
|
echo sprintf(" %ss: %d orphaned values\n", $et['label'], count($orphanedValues));
|
||||||
|
|
||||||
|
foreach ($orphanedValues as $ov) {
|
||||||
|
if (!$ov['model_type_id']) {
|
||||||
|
++$skippedCount;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentCf = $conn->fetchAssociative(
|
||||||
|
"SELECT id FROM custom_fields WHERE {$et['cfModelTypeFk']} = ? AND name = ? LIMIT 1",
|
||||||
|
[$ov['model_type_id'], $ov['field_name']]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$currentCf) {
|
||||||
|
// No matching CF on current ModelType — skip but keep value
|
||||||
|
++$skippedCount;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingValue = $conn->fetchAssociative(
|
||||||
|
"SELECT id, value FROM custom_field_values WHERE {$et['cfvFk']} = ? AND customfieldid = ?",
|
||||||
|
[$ov['entity_id'], $currentCf['id']]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existingValue) {
|
||||||
|
if (('' === $existingValue['value'] || null === $existingValue['value']) && '' !== $ov['value'] && null !== $ov['value']) {
|
||||||
|
echo sprintf(" MIGRATE: %s '%s' field '%s' = '%s'\n", $et['label'], $ov['entity_name'], $ov['field_name'], $ov['value']);
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$ov['value'], $existingValue['id']]);
|
||||||
|
}
|
||||||
|
++$migratedCount;
|
||||||
|
}
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]);
|
||||||
|
}
|
||||||
|
++$deletedOrphanedCfv;
|
||||||
|
} else {
|
||||||
|
echo sprintf(" REASSIGN: %s '%s' field '%s' = '%s'\n", $et['label'], $ov['entity_name'], $ov['field_name'], $ov['value']);
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('UPDATE custom_field_values SET customfieldid = ? WHERE id = ?', [$currentCf['id'], $ov['cfv_id']]);
|
||||||
|
}
|
||||||
|
++$migratedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PART 2: Restore composant values from audit logs
|
||||||
|
// ============================================================
|
||||||
|
echo "\n--- PART 2: Restore values from audit logs ---\n\n";
|
||||||
|
|
||||||
|
$deletionLogs = $conn->fetchAllAssociative("
|
||||||
|
SELECT al.entityid, al.entitytype, al.diff::text as diff
|
||||||
|
FROM audit_logs al
|
||||||
|
WHERE al.diff::text LIKE '%customField%'
|
||||||
|
AND al.diff::text LIKE '%\"to\":null%'
|
||||||
|
ORDER BY al.createdat DESC
|
||||||
|
");
|
||||||
|
|
||||||
|
echo sprintf(" Found %d audit entries with deleted values\n", count($deletionLogs));
|
||||||
|
|
||||||
|
foreach ($deletionLogs as $log) {
|
||||||
|
$diff = json_decode($log['diff'], true);
|
||||||
|
$entityType = $log['entitytype'];
|
||||||
|
|
||||||
|
$cfvFk = match ($entityType) {
|
||||||
|
'piece' => 'pieceid',
|
||||||
|
'composant' => 'composantid',
|
||||||
|
'product' => 'productid',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
if (!$cfvFk) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($diff as $key => $change) {
|
||||||
|
if (!str_starts_with($key, 'customField:')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (null !== $change['to']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$oldValue = $change['from'];
|
||||||
|
if (null === $oldValue || '' === $oldValue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fieldName = substr($key, strlen('customField:'));
|
||||||
|
|
||||||
|
$cfv = $conn->fetchAssociative(
|
||||||
|
"SELECT cfv.id, cfv.value FROM custom_field_values cfv JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE cfv.{$cfvFk} = ? AND cf.name = ?",
|
||||||
|
[$log['entityid'], $fieldName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$cfv) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('' !== $cfv['value'] && null !== $cfv['value']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf(" RESTORE: %s %s field '%s' = '%s'\n", $entityType, $log['entityid'], $fieldName, $oldValue);
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$oldValue, $cfv['id']]);
|
||||||
|
}
|
||||||
|
++$restoredCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PART 3: Clean orphaned CF definitions
|
||||||
|
// ============================================================
|
||||||
|
echo "\n--- PART 3: Clean orphaned CF definitions ---\n\n";
|
||||||
|
|
||||||
|
$orphanedCfs = $conn->fetchAllAssociative('
|
||||||
|
SELECT cf.id FROM custom_fields cf
|
||||||
|
WHERE cf.typecomposantid IS NULL AND cf.typepieceid IS NULL AND cf.typeproductid IS NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM custom_field_values cfv WHERE cfv.customfieldid = cf.id)
|
||||||
|
');
|
||||||
|
|
||||||
|
echo sprintf(" %d orphaned CF definitions to delete\n", count($orphanedCfs));
|
||||||
|
foreach ($orphanedCfs as $cf) {
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('DELETE FROM custom_fields WHERE id = ?', [$cf['id']]);
|
||||||
|
}
|
||||||
|
++$deletedOrphanedCf;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf("\n=== SUMMARY ===\n");
|
||||||
|
echo sprintf("Values migrated/reassigned: %d\n", $migratedCount);
|
||||||
|
echo sprintf("Values restored from audit: %d\n", $restoredCount);
|
||||||
|
echo sprintf("Orphaned CFValues cleaned: %d\n", $deletedOrphanedCfv);
|
||||||
|
echo sprintf("Orphaned CF definitions deleted: %d\n", $deletedOrphanedCf);
|
||||||
|
echo sprintf("Skipped (no matching CF on ModelType): %d\n", $skippedCount);
|
||||||
|
echo "=== DONE ===\n";
|
||||||
266
scripts/fix-prod-recreate-and-migrate.php
Normal file
266
scripts/fix-prod-recreate-and-migrate.php
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full prod fix:
|
||||||
|
* 1. Re-create missing CustomField definitions on ModelTypes (from orphaned CFs that still have values)
|
||||||
|
* 2. Migrate orphaned CFValues to the newly created CFs
|
||||||
|
* 3. Restore deleted values from audit logs
|
||||||
|
* 4. Clean up orphaned CFs with no remaining values
|
||||||
|
*
|
||||||
|
* Usage: php scripts/fix-prod-recreate-and-migrate.php [--dry-run]
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use Doctrine\DBAL\DriverManager;
|
||||||
|
|
||||||
|
$dryRun = in_array('--dry-run', $argv, true);
|
||||||
|
|
||||||
|
$conn = DriverManager::getConnection([
|
||||||
|
'driver' => 'pdo_pgsql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => 'inventory',
|
||||||
|
'user' => 'ferme_user',
|
||||||
|
'password' => 'fermerecette',
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n";
|
||||||
|
|
||||||
|
$createdCfCount = 0;
|
||||||
|
$migratedCount = 0;
|
||||||
|
$restoredCount = 0;
|
||||||
|
$deletedOrphanedCfv = 0;
|
||||||
|
$deletedOrphanedCf = 0;
|
||||||
|
|
||||||
|
$entityTypes = [
|
||||||
|
['label' => 'piece', 'entityTable' => 'pieces', 'cfvFk' => 'pieceid', 'modelTypeFk' => 'typepieceid', 'cfFk' => 'typepieceid'],
|
||||||
|
['label' => 'composant', 'entityTable' => 'composants', 'cfvFk' => 'composantid', 'modelTypeFk' => 'typecomposantid', 'cfFk' => 'typecomposantid'],
|
||||||
|
['label' => 'product', 'entityTable' => 'products', 'cfvFk' => 'productid', 'modelTypeFk' => 'typeproductid', 'cfFk' => 'typeproductid'],
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PART 1: Re-create missing CF definitions on ModelTypes
|
||||||
|
// ============================================================
|
||||||
|
echo "--- PART 1: Re-create missing CF definitions ---\n\n";
|
||||||
|
|
||||||
|
foreach ($entityTypes as $et) {
|
||||||
|
// Find distinct (ModelType, field name, type) from orphaned CFs that have values
|
||||||
|
$missingDefs = $conn->fetchAllAssociative("
|
||||||
|
SELECT e.{$et['modelTypeFk']} as model_type_id,
|
||||||
|
mt.name as model_type_name,
|
||||||
|
cf_orphan.name as field_name,
|
||||||
|
MIN(cf_orphan.type) as field_type,
|
||||||
|
BOOL_OR(COALESCE(cf_orphan.required, false)) as field_required,
|
||||||
|
MIN(cf_orphan.options::text) as field_options,
|
||||||
|
MIN(cf_orphan.defaultvalue) as field_default
|
||||||
|
FROM custom_field_values cfv
|
||||||
|
JOIN custom_fields cf_orphan ON cf_orphan.id = cfv.customfieldid
|
||||||
|
JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']}
|
||||||
|
JOIN model_types mt ON mt.id = e.{$et['modelTypeFk']}
|
||||||
|
WHERE cfv.{$et['cfvFk']} IS NOT NULL
|
||||||
|
AND cf_orphan.typecomposantid IS NULL
|
||||||
|
AND cf_orphan.typepieceid IS NULL
|
||||||
|
AND cf_orphan.typeproductid IS NULL
|
||||||
|
GROUP BY e.{$et['modelTypeFk']}, mt.name, cf_orphan.name
|
||||||
|
ORDER BY mt.name, cf_orphan.name
|
||||||
|
");
|
||||||
|
|
||||||
|
foreach ($missingDefs as $def) {
|
||||||
|
// Check if this CF already exists on the ModelType
|
||||||
|
$exists = $conn->fetchOne(
|
||||||
|
"SELECT COUNT(*) FROM custom_fields WHERE {$et['cfFk']} = ? AND name = ?",
|
||||||
|
[$def['model_type_id'], $def['field_name']]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($exists > 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next orderIndex
|
||||||
|
$maxOrder = $conn->fetchOne(
|
||||||
|
"SELECT COALESCE(MAX(orderindex), -1) FROM custom_fields WHERE {$et['cfFk']} = ?",
|
||||||
|
[$def['model_type_id']]
|
||||||
|
);
|
||||||
|
$nextOrder = ((int) $maxOrder) + 1;
|
||||||
|
|
||||||
|
// Generate CUID-like ID
|
||||||
|
$newId = 'cl' . bin2hex(random_bytes(12));
|
||||||
|
|
||||||
|
echo sprintf(" CREATE CF: ModelType '%s' (%s) + field '%s' (type=%s)\n",
|
||||||
|
$def['model_type_name'], $et['label'], $def['field_name'], $def['field_type']);
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
$options = $def['field_options'];
|
||||||
|
if (null !== $options && 'null' === $options) {
|
||||||
|
$options = null;
|
||||||
|
}
|
||||||
|
$required = !empty($def['field_required']) && 'f' !== $def['field_required'];
|
||||||
|
$conn->executeStatement(
|
||||||
|
"INSERT INTO custom_fields (id, name, type, required, options, defaultvalue, orderindex, {$et['cfFk']})
|
||||||
|
VALUES (?, ?, ?, ?::boolean, ?::json, ?, ?, ?)",
|
||||||
|
[
|
||||||
|
$newId,
|
||||||
|
$def['field_name'],
|
||||||
|
$def['field_type'],
|
||||||
|
$required ? 'true' : 'false',
|
||||||
|
$options,
|
||||||
|
$def['field_default'],
|
||||||
|
$nextOrder,
|
||||||
|
$def['model_type_id'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
++$createdCfCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf("\n Created %d CF definitions\n\n", $createdCfCount);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PART 2: Migrate orphaned CFValues to current CFs
|
||||||
|
// ============================================================
|
||||||
|
echo "--- PART 2: Migrate orphaned CFValues ---\n\n";
|
||||||
|
|
||||||
|
foreach ($entityTypes as $et) {
|
||||||
|
$orphanedValues = $conn->fetchAllAssociative("
|
||||||
|
SELECT cfv.id as cfv_id, cfv.value, cfv.{$et['cfvFk']} as entity_id,
|
||||||
|
cf_old.name as field_name,
|
||||||
|
e.name as entity_name, e.{$et['modelTypeFk']} as model_type_id
|
||||||
|
FROM custom_field_values cfv
|
||||||
|
JOIN custom_fields cf_old ON cf_old.id = cfv.customfieldid
|
||||||
|
JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']}
|
||||||
|
WHERE cfv.{$et['cfvFk']} IS NOT NULL
|
||||||
|
AND cf_old.typecomposantid IS NULL
|
||||||
|
AND cf_old.typepieceid IS NULL
|
||||||
|
AND cf_old.typeproductid IS NULL
|
||||||
|
ORDER BY e.name, cf_old.name
|
||||||
|
");
|
||||||
|
|
||||||
|
$migrated = 0;
|
||||||
|
$cleaned = 0;
|
||||||
|
|
||||||
|
foreach ($orphanedValues as $ov) {
|
||||||
|
if (!$ov['model_type_id']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentCf = $conn->fetchAssociative(
|
||||||
|
"SELECT id FROM custom_fields WHERE {$et['cfFk']} = ? AND name = ? LIMIT 1",
|
||||||
|
[$ov['model_type_id'], $ov['field_name']]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$currentCf) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingValue = $conn->fetchAssociative(
|
||||||
|
"SELECT id, value FROM custom_field_values WHERE {$et['cfvFk']} = ? AND customfieldid = ?",
|
||||||
|
[$ov['entity_id'], $currentCf['id']]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existingValue) {
|
||||||
|
if (('' === $existingValue['value'] || null === $existingValue['value']) && '' !== $ov['value'] && null !== $ov['value']) {
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$ov['value'], $existingValue['id']]);
|
||||||
|
}
|
||||||
|
++$migrated;
|
||||||
|
}
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]);
|
||||||
|
}
|
||||||
|
++$cleaned;
|
||||||
|
} else {
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('UPDATE custom_field_values SET customfieldid = ? WHERE id = ?', [$currentCf['id'], $ov['cfv_id']]);
|
||||||
|
}
|
||||||
|
++$migrated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf(" %ss: %d migrated, %d cleaned\n", $et['label'], $migrated, $cleaned);
|
||||||
|
$migratedCount += $migrated;
|
||||||
|
$deletedOrphanedCfv += $cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PART 3: Restore values from audit logs
|
||||||
|
// ============================================================
|
||||||
|
echo "\n--- PART 3: Restore values from audit logs ---\n\n";
|
||||||
|
|
||||||
|
$deletionLogs = $conn->fetchAllAssociative("
|
||||||
|
SELECT al.entityid, al.entitytype, al.diff::text as diff
|
||||||
|
FROM audit_logs al
|
||||||
|
WHERE al.diff::text LIKE '%customField%'
|
||||||
|
AND al.diff::text LIKE '%\"to\":null%'
|
||||||
|
ORDER BY al.createdat DESC
|
||||||
|
");
|
||||||
|
|
||||||
|
foreach ($deletionLogs as $log) {
|
||||||
|
$diff = json_decode($log['diff'], true);
|
||||||
|
$cfvFk = match ($log['entitytype']) {
|
||||||
|
'piece' => 'pieceid',
|
||||||
|
'composant' => 'composantid',
|
||||||
|
'product' => 'productid',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
if (!$cfvFk) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($diff as $key => $change) {
|
||||||
|
if (!str_starts_with($key, 'customField:')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (null !== $change['to'] || null === $change['from'] || '' === $change['from']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fieldName = substr($key, strlen('customField:'));
|
||||||
|
$cfv = $conn->fetchAssociative(
|
||||||
|
"SELECT cfv.id, cfv.value FROM custom_field_values cfv JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE cfv.{$cfvFk} = ? AND cf.name = ?",
|
||||||
|
[$log['entityid'], $fieldName]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$cfv || ('' !== $cfv['value'] && null !== $cfv['value'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf(" RESTORE: %s %s field '%s' = '%s'\n", $log['entitytype'], $log['entityid'], $fieldName, $change['from']);
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('UPDATE custom_field_values SET value = ? WHERE id = ?', [$change['from'], $cfv['id']]);
|
||||||
|
}
|
||||||
|
++$restoredCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf(" Restored: %d\n", $restoredCount);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PART 4: Clean orphaned CFs with no values
|
||||||
|
// ============================================================
|
||||||
|
echo "\n--- PART 4: Clean orphaned CF definitions ---\n\n";
|
||||||
|
|
||||||
|
$orphanedCfs = $conn->fetchAllAssociative('
|
||||||
|
SELECT id FROM custom_fields
|
||||||
|
WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM custom_field_values cfv WHERE cfv.customfieldid = id)
|
||||||
|
');
|
||||||
|
|
||||||
|
echo sprintf(" %d orphaned CFs to delete\n", count($orphanedCfs));
|
||||||
|
foreach ($orphanedCfs as $cf) {
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('DELETE FROM custom_fields WHERE id = ?', [$cf['id']]);
|
||||||
|
}
|
||||||
|
++$deletedOrphanedCf;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf("\n=== SUMMARY ===\n");
|
||||||
|
echo sprintf("CF definitions re-created: %d\n", $createdCfCount);
|
||||||
|
echo sprintf("Values migrated: %d\n", $migratedCount);
|
||||||
|
echo sprintf("Values restored from audit: %d\n", $restoredCount);
|
||||||
|
echo sprintf("Orphaned CFValues cleaned: %d\n", $deletedOrphanedCfv);
|
||||||
|
echo sprintf("Orphaned CFs deleted: %d\n", $deletedOrphanedCf);
|
||||||
|
echo "=== DONE ===\n";
|
||||||
233
scripts/migrate-orphaned-custom-fields.php
Normal file
233
scripts/migrate-orphaned-custom-fields.php
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate CustomFieldValues from orphaned CustomField definitions to current ones.
|
||||||
|
*
|
||||||
|
* When SkeletonStructureService::updateCustomFields() runs without IDs from the frontend,
|
||||||
|
* it deletes old CustomField definitions and creates new ones. But the FK on custom_fields
|
||||||
|
* is SET NULL (not CASCADE), so old CFs become orphaned (all type FKs = NULL) and their
|
||||||
|
* CFValues still exist but point to the wrong CF.
|
||||||
|
*
|
||||||
|
* This script:
|
||||||
|
* 1. For each entity (composant, piece, product) with CFValues pointing to orphaned CFs,
|
||||||
|
* find the matching current CF by name on the entity's ModelType
|
||||||
|
* 2. Reassign the CFValue to the current CF
|
||||||
|
* 3. Delete orphaned CF definitions that no longer have any values
|
||||||
|
*
|
||||||
|
* Usage: php scripts/migrate-orphaned-custom-fields.php [--dry-run]
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use Doctrine\DBAL\DriverManager;
|
||||||
|
|
||||||
|
$dryRun = in_array('--dry-run', $argv, true);
|
||||||
|
|
||||||
|
$env = getenv('APP_ENV') ?: 'local';
|
||||||
|
$conn = DriverManager::getConnection(match ($env) {
|
||||||
|
'prod' => [
|
||||||
|
'driver' => 'pdo_pgsql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => getenv('DB_NAME') ?: 'inventory',
|
||||||
|
'user' => 'ferme_user',
|
||||||
|
'password' => 'fermerecette',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'driver' => 'pdo_pgsql',
|
||||||
|
'host' => 'db',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => 'inventory',
|
||||||
|
'user' => 'root',
|
||||||
|
'password' => 'root',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n";
|
||||||
|
|
||||||
|
$migratedCount = 0;
|
||||||
|
$deletedCfCount = 0;
|
||||||
|
$skippedCount = 0;
|
||||||
|
$conflictCount = 0;
|
||||||
|
|
||||||
|
// Process each entity type
|
||||||
|
$entityTypes = [
|
||||||
|
[
|
||||||
|
'label' => 'composant',
|
||||||
|
'entityTable' => 'composants',
|
||||||
|
'cfvFk' => 'composantid',
|
||||||
|
'modelTypeFk' => 'typecomposantid',
|
||||||
|
'cfModelTypeFk' => 'typecomposantid',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'piece',
|
||||||
|
'entityTable' => 'pieces',
|
||||||
|
'cfvFk' => 'pieceid',
|
||||||
|
'modelTypeFk' => 'typepieceid',
|
||||||
|
'cfModelTypeFk' => 'typepieceid',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'label' => 'product',
|
||||||
|
'entityTable' => 'products',
|
||||||
|
'cfvFk' => 'productid',
|
||||||
|
'modelTypeFk' => 'typeproductid',
|
||||||
|
'cfModelTypeFk' => 'typeproductid',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($entityTypes as $et) {
|
||||||
|
echo sprintf("--- Processing %ss ---\n\n", $et['label']);
|
||||||
|
|
||||||
|
// Find all CFValues pointing to orphaned CFs for this entity type
|
||||||
|
$orphanedValues = $conn->fetchAllAssociative("
|
||||||
|
SELECT cfv.id as cfv_id, cfv.value, cfv.{$et['cfvFk']} as entity_id,
|
||||||
|
cf_old.id as old_cf_id, cf_old.name as field_name,
|
||||||
|
e.name as entity_name, e.{$et['modelTypeFk']} as model_type_id
|
||||||
|
FROM custom_field_values cfv
|
||||||
|
JOIN custom_fields cf_old ON cf_old.id = cfv.customfieldid
|
||||||
|
JOIN {$et['entityTable']} e ON e.id = cfv.{$et['cfvFk']}
|
||||||
|
WHERE cfv.{$et['cfvFk']} IS NOT NULL
|
||||||
|
AND cf_old.{$et['cfModelTypeFk']} IS NULL
|
||||||
|
AND cf_old.typecomposantid IS NULL
|
||||||
|
AND cf_old.typepieceid IS NULL
|
||||||
|
AND cf_old.typeproductid IS NULL
|
||||||
|
ORDER BY e.name, cf_old.name
|
||||||
|
");
|
||||||
|
|
||||||
|
echo sprintf(" Found %d orphaned custom field values.\n", count($orphanedValues));
|
||||||
|
|
||||||
|
foreach ($orphanedValues as $ov) {
|
||||||
|
if (!$ov['model_type_id']) {
|
||||||
|
echo sprintf(" SKIP: %s '%s' has no ModelType\n", $et['label'], $ov['entity_name']);
|
||||||
|
++$skippedCount;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the current CF definition on the ModelType with the same name
|
||||||
|
$currentCf = $conn->fetchAssociative("
|
||||||
|
SELECT id FROM custom_fields
|
||||||
|
WHERE {$et['cfModelTypeFk']} = ? AND name = ?
|
||||||
|
LIMIT 1
|
||||||
|
", [$ov['model_type_id'], $ov['field_name']]);
|
||||||
|
|
||||||
|
if (!$currentCf) {
|
||||||
|
echo sprintf(
|
||||||
|
" WARNING: No current CF '%s' on ModelType %s for %s '%s'\n",
|
||||||
|
$ov['field_name'],
|
||||||
|
$ov['model_type_id'],
|
||||||
|
$et['label'],
|
||||||
|
$ov['entity_name']
|
||||||
|
);
|
||||||
|
++$skippedCount;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if entity already has a CFValue for this current CF
|
||||||
|
$existingValue = $conn->fetchAssociative("
|
||||||
|
SELECT id, value FROM custom_field_values
|
||||||
|
WHERE {$et['cfvFk']} = ? AND customfieldid = ?
|
||||||
|
", [$ov['entity_id'], $currentCf['id']]);
|
||||||
|
|
||||||
|
if ($existingValue) {
|
||||||
|
// Current CF already has a value for this entity
|
||||||
|
if ('' !== $existingValue['value'] && null !== $existingValue['value']) {
|
||||||
|
// Both have values — conflict, skip
|
||||||
|
if ('' !== $ov['value'] && null !== $ov['value'] && $ov['value'] !== $existingValue['value']) {
|
||||||
|
echo sprintf(
|
||||||
|
" CONFLICT: %s '%s' field '%s': old='%s' vs current='%s' — keeping current\n",
|
||||||
|
$et['label'],
|
||||||
|
$ov['entity_name'],
|
||||||
|
$ov['field_name'],
|
||||||
|
$ov['value'],
|
||||||
|
$existingValue['value']
|
||||||
|
);
|
||||||
|
++$conflictCount;
|
||||||
|
}
|
||||||
|
// Delete the orphaned value (current one is kept)
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]);
|
||||||
|
}
|
||||||
|
echo sprintf(
|
||||||
|
" DELETE orphaned: %s '%s' field '%s' (current has value '%s')\n",
|
||||||
|
$et['label'],
|
||||||
|
$ov['entity_name'],
|
||||||
|
$ov['field_name'],
|
||||||
|
$existingValue['value']
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Current value is empty, orphaned has data — update the current one and delete orphaned
|
||||||
|
if ('' !== $ov['value'] && null !== $ov['value']) {
|
||||||
|
echo sprintf(
|
||||||
|
" MIGRATE: %s '%s' field '%s' = '%s'\n",
|
||||||
|
$et['label'],
|
||||||
|
$ov['entity_name'],
|
||||||
|
$ov['field_name'],
|
||||||
|
$ov['value']
|
||||||
|
);
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement(
|
||||||
|
'UPDATE custom_field_values SET value = ? WHERE id = ?',
|
||||||
|
[$ov['value'], $existingValue['id']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
++$migratedCount;
|
||||||
|
}
|
||||||
|
// Delete the orphaned CFV
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$ov['cfv_id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No current CFV exists — reassign the orphaned one to the current CF
|
||||||
|
echo sprintf(
|
||||||
|
" REASSIGN: %s '%s' field '%s' = '%s'\n",
|
||||||
|
$et['label'],
|
||||||
|
$ov['entity_name'],
|
||||||
|
$ov['field_name'],
|
||||||
|
$ov['value']
|
||||||
|
);
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement(
|
||||||
|
'UPDATE custom_field_values SET customfieldid = ? WHERE id = ?',
|
||||||
|
[$currentCf['id'], $ov['cfv_id']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
++$migratedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up orphaned CF definitions with no remaining values
|
||||||
|
echo "--- Cleaning up orphaned CustomField definitions ---\n\n";
|
||||||
|
|
||||||
|
$orphanedCfs = $conn->fetchAllAssociative('
|
||||||
|
SELECT cf.id, cf.name
|
||||||
|
FROM custom_fields cf
|
||||||
|
WHERE cf.typecomposantid IS NULL
|
||||||
|
AND cf.typepieceid IS NULL
|
||||||
|
AND cf.typeproductid IS NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM custom_field_values cfv WHERE cfv.customfieldid = cf.id)
|
||||||
|
ORDER BY cf.name
|
||||||
|
');
|
||||||
|
|
||||||
|
echo sprintf("Found %d orphaned CustomField definitions with no values.\n", count($orphanedCfs));
|
||||||
|
|
||||||
|
foreach ($orphanedCfs as $cf) {
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('DELETE FROM custom_fields WHERE id = ?', [$cf['id']]);
|
||||||
|
}
|
||||||
|
++$deletedCfCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf("\n=== SUMMARY ===\n");
|
||||||
|
echo sprintf("Values migrated/reassigned: %d\n", $migratedCount);
|
||||||
|
echo sprintf("Orphaned CF definitions deleted: %d\n", $deletedCfCount);
|
||||||
|
echo sprintf("Skipped: %d\n", $skippedCount);
|
||||||
|
echo sprintf("Conflicts (kept current): %d\n", $conflictCount);
|
||||||
|
echo "=== DONE ===\n";
|
||||||
196
scripts/restore-custom-field-values.php
Normal file
196
scripts/restore-custom-field-values.php
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Script to restore custom field values lost during ModelType sync.
|
||||||
|
*
|
||||||
|
* Problem: SkeletonStructureService::updateCustomFields() deletes and recreates
|
||||||
|
* CustomField definitions when frontend doesn't send IDs. This cascades to
|
||||||
|
* deleting all CustomFieldValues.
|
||||||
|
*
|
||||||
|
* This script:
|
||||||
|
* 1. Pieces: Restores values from audit_logs (the "from" values in deletion diffs)
|
||||||
|
* 2. Composants: Removes duplicate empty CustomFieldValues created by sync
|
||||||
|
*
|
||||||
|
* Usage: php scripts/restore-custom-field-values.php [--dry-run]
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use Doctrine\DBAL\DriverManager;
|
||||||
|
|
||||||
|
$dryRun = in_array('--dry-run', $argv, true);
|
||||||
|
|
||||||
|
$env = getenv('APP_ENV') ?: 'local';
|
||||||
|
$conn = DriverManager::getConnection(match ($env) {
|
||||||
|
'prod' => [
|
||||||
|
'driver' => 'pdo_pgsql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => getenv('DB_NAME') ?: 'inventory',
|
||||||
|
'user' => 'ferme_user',
|
||||||
|
'password' => 'fermerecette',
|
||||||
|
],
|
||||||
|
default => [
|
||||||
|
'driver' => 'pdo_pgsql',
|
||||||
|
'host' => 'db',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => 'inventory',
|
||||||
|
'user' => 'root',
|
||||||
|
'password' => 'root',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
echo $dryRun ? "=== DRY RUN MODE ===\n\n" : "=== LIVE MODE ===\n\n";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PART 1: Restore piece custom field values from audit logs
|
||||||
|
// ============================================================
|
||||||
|
echo "--- PART 1: Restoring piece custom field values ---\n\n";
|
||||||
|
|
||||||
|
// Find all deletion audit entries (where values went from X to null on 2026-03-13)
|
||||||
|
$deletionLogs = $conn->fetchAllAssociative("
|
||||||
|
SELECT al.entityid, al.diff::text as diff, p.name as piece_name
|
||||||
|
FROM audit_logs al
|
||||||
|
JOIN pieces p ON p.id = al.entityid
|
||||||
|
WHERE al.entitytype = 'piece'
|
||||||
|
AND al.action = 'update'
|
||||||
|
AND al.diff::text LIKE '%\"to\":null%'
|
||||||
|
AND al.diff::text LIKE '%customField%'
|
||||||
|
AND al.createdat >= '2026-03-13'
|
||||||
|
ORDER BY p.name
|
||||||
|
");
|
||||||
|
|
||||||
|
echo sprintf("Found %d pieces with deleted custom field values.\n\n", count($deletionLogs));
|
||||||
|
|
||||||
|
$restoredCount = 0;
|
||||||
|
$errorCount = 0;
|
||||||
|
|
||||||
|
foreach ($deletionLogs as $log) {
|
||||||
|
$pieceId = $log['entityid'];
|
||||||
|
$pieceName = $log['piece_name'];
|
||||||
|
$diff = json_decode($log['diff'], true);
|
||||||
|
|
||||||
|
foreach ($diff as $key => $change) {
|
||||||
|
if (!str_starts_with($key, 'customField:')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (null !== $change['to']) {
|
||||||
|
continue; // Not a deletion
|
||||||
|
}
|
||||||
|
$oldValue = $change['from'];
|
||||||
|
if (null === $oldValue || '' === $oldValue) {
|
||||||
|
continue; // Nothing to restore
|
||||||
|
}
|
||||||
|
|
||||||
|
$fieldName = substr($key, strlen('customField:'));
|
||||||
|
|
||||||
|
// Find the current CustomFieldValue for this piece + field name
|
||||||
|
$cfv = $conn->fetchAssociative('
|
||||||
|
SELECT cfv.id, cfv.value, cf.name as field_name
|
||||||
|
FROM custom_field_values cfv
|
||||||
|
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||||
|
WHERE cfv.pieceid = ?
|
||||||
|
AND cf.name = ?
|
||||||
|
', [$pieceId, $fieldName]);
|
||||||
|
|
||||||
|
if (!$cfv) {
|
||||||
|
echo sprintf(" WARNING: No CustomFieldValue found for piece '%s' field '%s' — skipping\n", $pieceName, $fieldName);
|
||||||
|
++$errorCount;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('' !== $cfv['value'] && null !== $cfv['value']) {
|
||||||
|
echo sprintf(" SKIP: Piece '%s' field '%s' already has value '%s' (would restore '%s')\n", $pieceName, $fieldName, $cfv['value'], $oldValue);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf(" RESTORE: Piece '%s' field '%s' = '%s'\n", $pieceName, $fieldName, $oldValue);
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement(
|
||||||
|
'UPDATE custom_field_values SET value = ? WHERE id = ?',
|
||||||
|
[$oldValue, $cfv['id']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
++$restoredCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf("\nPieces: %d values restored, %d errors.\n\n", $restoredCount, $errorCount);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PART 2: Remove duplicate empty composant CustomFieldValues
|
||||||
|
// ============================================================
|
||||||
|
echo "--- PART 2: Cleaning duplicate composant custom field values ---\n\n";
|
||||||
|
|
||||||
|
// Find composants that have duplicate CFVs (same composantid + same field name, one with value and one empty)
|
||||||
|
$duplicates = $conn->fetchAllAssociative("
|
||||||
|
SELECT cfv_empty.id as empty_cfv_id, c.name as composant_name, cf_empty.name as field_name,
|
||||||
|
cfv_filled.value as existing_value
|
||||||
|
FROM custom_field_values cfv_empty
|
||||||
|
JOIN custom_fields cf_empty ON cf_empty.id = cfv_empty.customfieldid
|
||||||
|
JOIN composants c ON c.id = cfv_empty.composantid
|
||||||
|
JOIN custom_field_values cfv_filled ON cfv_filled.composantid = cfv_empty.composantid
|
||||||
|
JOIN custom_fields cf_filled ON cf_filled.id = cfv_filled.customfieldid
|
||||||
|
WHERE cfv_empty.composantid IS NOT NULL
|
||||||
|
AND cfv_empty.value = ''
|
||||||
|
AND cf_empty.name = cf_filled.name
|
||||||
|
AND cfv_filled.value != ''
|
||||||
|
AND cfv_filled.id != cfv_empty.id
|
||||||
|
ORDER BY c.name, cf_empty.name
|
||||||
|
");
|
||||||
|
|
||||||
|
echo sprintf("Found %d duplicate empty custom field values on composants.\n\n", count($duplicates));
|
||||||
|
|
||||||
|
$deletedDuplicates = 0;
|
||||||
|
foreach ($duplicates as $dup) {
|
||||||
|
echo sprintf(
|
||||||
|
" DELETE empty duplicate: Composant '%s' field '%s' (has value '%s' in other record)\n",
|
||||||
|
$dup['composant_name'],
|
||||||
|
$dup['field_name'],
|
||||||
|
$dup['existing_value']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$dup['empty_cfv_id']]);
|
||||||
|
}
|
||||||
|
++$deletedDuplicates;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also find composants with duplicate empty CFVs (both empty, same field name - keep one, delete the other)
|
||||||
|
$emptyDuplicates = $conn->fetchAllAssociative("
|
||||||
|
SELECT cfv2.id as duplicate_id, c.name as composant_name, cf2.name as field_name
|
||||||
|
FROM custom_field_values cfv1
|
||||||
|
JOIN custom_fields cf1 ON cf1.id = cfv1.customfieldid
|
||||||
|
JOIN custom_field_values cfv2 ON cfv2.composantid = cfv1.composantid AND cfv2.id > cfv1.id
|
||||||
|
JOIN custom_fields cf2 ON cf2.id = cfv2.customfieldid
|
||||||
|
JOIN composants c ON c.id = cfv1.composantid
|
||||||
|
WHERE cfv1.composantid IS NOT NULL
|
||||||
|
AND cfv1.value = ''
|
||||||
|
AND cfv2.value = ''
|
||||||
|
AND cf1.name = cf2.name
|
||||||
|
ORDER BY c.name, cf2.name
|
||||||
|
");
|
||||||
|
|
||||||
|
echo sprintf("\nFound %d duplicate empty-empty custom field values on composants.\n\n", count($emptyDuplicates));
|
||||||
|
|
||||||
|
foreach ($emptyDuplicates as $dup) {
|
||||||
|
echo sprintf(
|
||||||
|
" DELETE empty-empty duplicate: Composant '%s' field '%s'\n",
|
||||||
|
$dup['composant_name'],
|
||||||
|
$dup['field_name']
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$dryRun) {
|
||||||
|
$conn->executeStatement('DELETE FROM custom_field_values WHERE id = ?', [$dup['duplicate_id']]);
|
||||||
|
}
|
||||||
|
++$deletedDuplicates;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo sprintf("\nComposants: %d duplicate values removed.\n", $deletedDuplicates);
|
||||||
|
|
||||||
|
echo "\n=== DONE ===\n";
|
||||||
95
scripts/verify-prod-health.php
Normal file
95
scripts/verify-prod-health.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__.'/../vendor/autoload.php';
|
||||||
|
|
||||||
|
use Doctrine\DBAL\DriverManager;
|
||||||
|
|
||||||
|
$conn = DriverManager::getConnection([
|
||||||
|
'driver' => 'pdo_pgsql',
|
||||||
|
'host' => 'localhost',
|
||||||
|
'port' => 5432,
|
||||||
|
'dbname' => 'inventory',
|
||||||
|
'user' => 'ferme_user',
|
||||||
|
'password' => 'fermerecette',
|
||||||
|
]);
|
||||||
|
|
||||||
|
echo "=== CUSTOM FIELDS HEALTH CHECK ===\n\n";
|
||||||
|
|
||||||
|
// 1. Orphaned CFs (should be 0)
|
||||||
|
$orphanedCfs = $conn->fetchOne('SELECT COUNT(*) FROM custom_fields WHERE typecomposantid IS NULL AND typepieceid IS NULL AND typeproductid IS NULL');
|
||||||
|
echo sprintf("1. Orphaned CF definitions: %d %s\n", $orphanedCfs, 0 == $orphanedCfs ? '[OK]' : '[PROBLEM]');
|
||||||
|
|
||||||
|
// 2. Orphaned CFValues (pointing to orphaned CFs)
|
||||||
|
$orphanedCfvs = $conn->fetchOne('
|
||||||
|
SELECT COUNT(*) FROM custom_field_values cfv
|
||||||
|
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||||
|
WHERE cf.typecomposantid IS NULL AND cf.typepieceid IS NULL AND cf.typeproductid IS NULL
|
||||||
|
');
|
||||||
|
echo sprintf("2. Orphaned CF values: %d %s\n", $orphanedCfvs, 0 == $orphanedCfvs ? '[OK]' : '[PROBLEM]');
|
||||||
|
|
||||||
|
// 3. Duplicate CFValues (same entity + same field name)
|
||||||
|
$duplicatePieces = $conn->fetchOne("
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT cfv.pieceid, cf.name, COUNT(*) as cnt
|
||||||
|
FROM custom_field_values cfv
|
||||||
|
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||||
|
WHERE cfv.pieceid IS NOT NULL
|
||||||
|
GROUP BY cfv.pieceid, cf.name
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) t
|
||||||
|
");
|
||||||
|
$duplicateComposants = $conn->fetchOne("
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT cfv.composantid, cf.name, COUNT(*) as cnt
|
||||||
|
FROM custom_field_values cfv
|
||||||
|
JOIN custom_fields cf ON cf.id = cfv.customfieldid
|
||||||
|
WHERE cfv.composantid IS NOT NULL
|
||||||
|
GROUP BY cfv.composantid, cf.name
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) t
|
||||||
|
");
|
||||||
|
$totalDuplicates = $duplicatePieces + $duplicateComposants;
|
||||||
|
echo sprintf("3. Duplicate CF values: %d %s\n", $totalDuplicates, 0 == $totalDuplicates ? '[OK]' : '[PROBLEM]');
|
||||||
|
|
||||||
|
// 4. Spot check known pieces
|
||||||
|
echo "\n--- Spot checks ---\n";
|
||||||
|
|
||||||
|
$checks = [
|
||||||
|
['Arbre du palier pied E1', 'cl3d978dd4b071daff8fb185f7', 'pieceid', 'diamètre', '50'],
|
||||||
|
['Arbre du palier tête E1', 'cmkr0qjw5004s1eq6pen63x7j', 'pieceid', 'diamètre', '70'],
|
||||||
|
['Cage écureuil pied E1', 'clbe710810fd7ccd09811957b3', 'composantid', 'Diamètre', ''],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($checks as [$name, $id, $fk, $fieldName, $expectedValue]) {
|
||||||
|
$row = $conn->fetchAssociative(
|
||||||
|
"SELECT cfv.value FROM custom_field_values cfv JOIN custom_fields cf ON cf.id = cfv.customfieldid WHERE cfv.{$fk} = ? AND cf.name = ?",
|
||||||
|
[$id, $fieldName]
|
||||||
|
);
|
||||||
|
$value = $row ? $row['value'] : '(NOT FOUND)';
|
||||||
|
$ok = '' === $expectedValue ? ('' !== $value && null !== $value) : ($value === $expectedValue);
|
||||||
|
echo sprintf(" %s → %s = '%s' %s\n", $name, $fieldName, $value, $ok ? '[OK]' : '[CHECK]');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Summary of empty vs filled values
|
||||||
|
echo "\n--- Value fill rates ---\n";
|
||||||
|
$stats = $conn->fetchAllAssociative("
|
||||||
|
SELECT
|
||||||
|
CASE WHEN cfv.pieceid IS NOT NULL THEN 'piece'
|
||||||
|
WHEN cfv.composantid IS NOT NULL THEN 'composant'
|
||||||
|
WHEN cfv.productid IS NOT NULL THEN 'product'
|
||||||
|
ELSE 'unknown' END as entity_type,
|
||||||
|
COUNT(*) as total,
|
||||||
|
COUNT(*) FILTER (WHERE cfv.value != '' AND cfv.value IS NOT NULL) as filled,
|
||||||
|
COUNT(*) FILTER (WHERE cfv.value = '' OR cfv.value IS NULL) as empty
|
||||||
|
FROM custom_field_values cfv
|
||||||
|
GROUP BY entity_type
|
||||||
|
ORDER BY entity_type
|
||||||
|
");
|
||||||
|
foreach ($stats as $s) {
|
||||||
|
$pct = $s['total'] > 0 ? round(100 * $s['filled'] / $s['total']) : 0;
|
||||||
|
echo sprintf(" %s: %d/%d filled (%d%%)\n", $s['entity_type'], $s['filled'], $s['total'], $pct);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\n=== DONE ===\n";
|
||||||
327
src/Command/CheckMissingCustomFieldValuesCommand.php
Normal file
327
src/Command/CheckMissingCustomFieldValuesCommand.php
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\AuditLog;
|
||||||
|
use App\Entity\Composant;
|
||||||
|
use App\Entity\CustomField;
|
||||||
|
use App\Entity\CustomFieldValue;
|
||||||
|
use App\Entity\Piece;
|
||||||
|
use App\Repository\AuditLogRepository;
|
||||||
|
use App\Repository\ComposantRepository;
|
||||||
|
use App\Repository\PieceRepository;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
use function array_key_exists;
|
||||||
|
use function array_slice;
|
||||||
|
use function count;
|
||||||
|
use function iconv;
|
||||||
|
use function in_array;
|
||||||
|
use function is_array;
|
||||||
|
use function is_string;
|
||||||
|
use function preg_replace;
|
||||||
|
use function sprintf;
|
||||||
|
use function str_starts_with;
|
||||||
|
use function strtolower;
|
||||||
|
use function trim;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:check-missing-custom-field-values',
|
||||||
|
description: 'List missing or empty custom field values for pieces and composants',
|
||||||
|
)]
|
||||||
|
final class CheckMissingCustomFieldValuesCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PieceRepository $pieces,
|
||||||
|
private readonly ComposantRepository $composants,
|
||||||
|
private readonly AuditLogRepository $auditLogs,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('entity', null, InputOption::VALUE_REQUIRED, 'piece, composant or all', 'all')
|
||||||
|
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Audit entries inspected per entity', '200')
|
||||||
|
->addOption('max-rows', null, InputOption::VALUE_REQUIRED, 'Maximum rows displayed in the final table', '300')
|
||||||
|
->addOption('recoverable-only', null, InputOption::VALUE_NONE, 'Show only rows recoverable from audit')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$entityScope = (string) $input->getOption('entity');
|
||||||
|
$limit = max(1, (int) $input->getOption('limit'));
|
||||||
|
$maxRows = max(1, (int) $input->getOption('max-rows'));
|
||||||
|
$recoverableOnly = (bool) $input->getOption('recoverable-only');
|
||||||
|
|
||||||
|
if (!in_array($entityScope, ['all', 'piece', 'composant'], true)) {
|
||||||
|
$io->error('Invalid --entity value. Use: all, piece, composant');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$counts = [
|
||||||
|
'piece' => 0,
|
||||||
|
'composant' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ('all' === $entityScope || 'piece' === $entityScope) {
|
||||||
|
foreach ($this->pieces->findAll() as $piece) {
|
||||||
|
if (!$piece instanceof Piece) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pieceRows = $this->inspectPiece($piece, $limit, $recoverableOnly);
|
||||||
|
$counts['piece'] += count($pieceRows);
|
||||||
|
$rows = [...$rows, ...$pieceRows];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('all' === $entityScope || 'composant' === $entityScope) {
|
||||||
|
foreach ($this->composants->findAll() as $composant) {
|
||||||
|
if (!$composant instanceof Composant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$composantRows = $this->inspectComposant($composant, $limit, $recoverableOnly);
|
||||||
|
$counts['composant'] += count($composantRows);
|
||||||
|
$rows = [...$rows, ...$composantRows];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $rows) {
|
||||||
|
$io->success('No missing or empty custom field values found.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$displayRows = array_slice($rows, 0, $maxRows);
|
||||||
|
$io->table(
|
||||||
|
['Entity', 'ID', 'Name', 'Reference', 'Category', 'Field', 'Issue', 'Recoverable', 'Audit value'],
|
||||||
|
$displayRows,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (count($rows) > $maxRows) {
|
||||||
|
$io->warning(sprintf('Output truncated: showing %d of %d row(s).', $maxRows, count($rows)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->note(sprintf(
|
||||||
|
'Missing/empty values found: pieces=%d, composants=%d, total=%d.',
|
||||||
|
$counts['piece'],
|
||||||
|
$counts['composant'],
|
||||||
|
count($rows),
|
||||||
|
));
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<int, string>>
|
||||||
|
*/
|
||||||
|
private function inspectPiece(Piece $piece, int $limit, bool $recoverableOnly): array
|
||||||
|
{
|
||||||
|
$type = $piece->getTypePiece();
|
||||||
|
if (null === $type) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->inspectEntity(
|
||||||
|
entityType: 'piece',
|
||||||
|
entityId: (string) $piece->getId(),
|
||||||
|
entityName: $piece->getName(),
|
||||||
|
entityReference: $piece->getReference() ?? '',
|
||||||
|
typeName: $type->getName(),
|
||||||
|
definitions: $type->getPieceCustomFields(),
|
||||||
|
currentValues: $piece->getCustomFieldValues(),
|
||||||
|
limit: $limit,
|
||||||
|
recoverableOnly: $recoverableOnly,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<int, string>>
|
||||||
|
*/
|
||||||
|
private function inspectComposant(Composant $composant, int $limit, bool $recoverableOnly): array
|
||||||
|
{
|
||||||
|
$type = $composant->getTypeComposant();
|
||||||
|
if (null === $type) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->inspectEntity(
|
||||||
|
entityType: 'composant',
|
||||||
|
entityId: (string) $composant->getId(),
|
||||||
|
entityName: $composant->getName(),
|
||||||
|
entityReference: $composant->getReference() ?? '',
|
||||||
|
typeName: $type->getName(),
|
||||||
|
definitions: $type->getComponentCustomFields(),
|
||||||
|
currentValues: $composant->getCustomFieldValues(),
|
||||||
|
limit: $limit,
|
||||||
|
recoverableOnly: $recoverableOnly,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<int, string>>
|
||||||
|
*/
|
||||||
|
private function inspectEntity(
|
||||||
|
string $entityType,
|
||||||
|
string $entityId,
|
||||||
|
string $entityName,
|
||||||
|
string $entityReference,
|
||||||
|
string $typeName,
|
||||||
|
Collection $definitions,
|
||||||
|
Collection $currentValues,
|
||||||
|
int $limit,
|
||||||
|
bool $recoverableOnly,
|
||||||
|
): array {
|
||||||
|
if (0 === $definitions->count()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentValuesByFieldId = $this->indexCurrentValues($currentValues);
|
||||||
|
$history = $this->auditLogs->findEntityHistory($entityType, $entityId, $limit);
|
||||||
|
$historicalValues = $this->extractHistoricalValues($history);
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($definitions as $definition) {
|
||||||
|
if (!$definition instanceof CustomField) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentValue = $currentValuesByFieldId[$definition->getId()] ?? null;
|
||||||
|
$issue = null;
|
||||||
|
|
||||||
|
if (!$currentValue instanceof CustomFieldValue) {
|
||||||
|
$issue = 'missing';
|
||||||
|
} elseif ('' === trim($currentValue->getValue())) {
|
||||||
|
$issue = 'empty';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $issue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$auditCandidate = $historicalValues[$this->normalizeFieldName($definition->getName())] ?? null;
|
||||||
|
if ($recoverableOnly && null === $auditCandidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rows[] = [
|
||||||
|
$entityType,
|
||||||
|
$entityId,
|
||||||
|
$entityName,
|
||||||
|
$entityReference,
|
||||||
|
$typeName,
|
||||||
|
$definition->getName(),
|
||||||
|
$issue,
|
||||||
|
$auditCandidate ? 'yes' : 'no',
|
||||||
|
$auditCandidate['value'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<AuditLog> $history
|
||||||
|
*
|
||||||
|
* @return array<string, array{value: string}>
|
||||||
|
*/
|
||||||
|
private function extractHistoricalValues(array $history): array
|
||||||
|
{
|
||||||
|
$values = [];
|
||||||
|
|
||||||
|
foreach ($history as $log) {
|
||||||
|
$diff = $log->getDiff();
|
||||||
|
if (!is_array($diff)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($diff as $field => $change) {
|
||||||
|
if (!is_string($field) || !str_starts_with($field, 'customField:') || !is_array($change)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedName = $this->normalizeFieldName(trim(substr($field, 12)));
|
||||||
|
if ('' === $normalizedName || array_key_exists($normalizedName, $values)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = $this->extractCandidateValue($change);
|
||||||
|
if (null === $candidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$values[$normalizedName] = ['value' => $candidate];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{from?: mixed, to?: mixed} $change
|
||||||
|
*/
|
||||||
|
private function extractCandidateValue(array $change): ?string
|
||||||
|
{
|
||||||
|
$to = $change['to'] ?? null;
|
||||||
|
if (is_string($to) && '' !== trim($to)) {
|
||||||
|
return $to;
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = $change['from'] ?? null;
|
||||||
|
if (is_string($from) && '' !== trim($from)) {
|
||||||
|
return $from;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, CustomFieldValue>
|
||||||
|
*/
|
||||||
|
private function indexCurrentValues(Collection $customFieldValues): array
|
||||||
|
{
|
||||||
|
$indexed = [];
|
||||||
|
|
||||||
|
foreach ($customFieldValues as $customFieldValue) {
|
||||||
|
if (!$customFieldValue instanceof CustomFieldValue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$indexed[$customFieldValue->getCustomField()->getId()] = $customFieldValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $indexed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeFieldName(string $name): string
|
||||||
|
{
|
||||||
|
$normalized = trim($name);
|
||||||
|
if ('' === $normalized) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
|
||||||
|
if (false !== $transliterated) {
|
||||||
|
$normalized = $transliterated;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtolower($normalized);
|
||||||
|
$normalized = (string) preg_replace('/[^a-z0-9]+/', ' ', $normalized);
|
||||||
|
|
||||||
|
return trim((string) preg_replace('/\s+/', ' ', $normalized));
|
||||||
|
}
|
||||||
|
}
|
||||||
266
src/Command/RestorePieceCustomFieldValuesCommand.php
Normal file
266
src/Command/RestorePieceCustomFieldValuesCommand.php
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\AuditLog;
|
||||||
|
use App\Entity\CustomField;
|
||||||
|
use App\Entity\CustomFieldValue;
|
||||||
|
use App\Entity\Piece;
|
||||||
|
use App\Repository\AuditLogRepository;
|
||||||
|
use App\Repository\PieceRepository;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
use function array_key_exists;
|
||||||
|
use function count;
|
||||||
|
use function is_array;
|
||||||
|
use function is_string;
|
||||||
|
use function ksort;
|
||||||
|
use function preg_replace;
|
||||||
|
use function sprintf;
|
||||||
|
use function str_starts_with;
|
||||||
|
use function strtolower;
|
||||||
|
use function trim;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:restore-piece-custom-field-values',
|
||||||
|
description: 'Restore missing or empty piece custom field values from audit history',
|
||||||
|
)]
|
||||||
|
final class RestorePieceCustomFieldValuesCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PieceRepository $pieces,
|
||||||
|
private readonly AuditLogRepository $auditLogs,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addArgument('pieceId', InputArgument::REQUIRED, 'Piece ID to restore')
|
||||||
|
->addOption('apply', null, InputOption::VALUE_NONE, 'Persist restored values instead of dry-run mode')
|
||||||
|
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of audit entries to inspect', '500')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$pieceId = (string) $input->getArgument('pieceId');
|
||||||
|
$apply = (bool) $input->getOption('apply');
|
||||||
|
$limit = max(1, (int) $input->getOption('limit'));
|
||||||
|
|
||||||
|
$piece = $this->pieces->find($pieceId);
|
||||||
|
if (!$piece instanceof Piece) {
|
||||||
|
$io->error(sprintf('Piece not found: %s', $pieceId));
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $piece->getTypePiece();
|
||||||
|
if (null === $type) {
|
||||||
|
$io->error('This piece has no category (typePiece).');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$definitions = $type->getPieceCustomFields();
|
||||||
|
if (0 === $definitions->count()) {
|
||||||
|
$io->warning('This piece category has no current custom field definitions.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$history = $this->auditLogs->findEntityHistory('piece', $pieceId, $limit);
|
||||||
|
if ([] === $history) {
|
||||||
|
$io->warning('No audit history found for this piece.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$historicalValues = $this->extractHistoricalValues($history);
|
||||||
|
if ([] === $historicalValues) {
|
||||||
|
$io->warning('No historical custom field values were found in audit logs.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentValuesByFieldId = $this->indexCurrentValues($piece->getCustomFieldValues());
|
||||||
|
$plannedRows = [];
|
||||||
|
$changesCount = 0;
|
||||||
|
|
||||||
|
foreach ($definitions as $definition) {
|
||||||
|
if (!$definition instanceof CustomField) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedName = $this->normalizeFieldName($definition->getName());
|
||||||
|
if ('' === $normalizedName || !isset($historicalValues[$normalizedName])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentValue = $currentValuesByFieldId[$definition->getId()] ?? null;
|
||||||
|
$shouldRestore = null === $currentValue || '' === trim($currentValue->getValue());
|
||||||
|
if (!$shouldRestore) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = $historicalValues[$normalizedName];
|
||||||
|
$plannedRows[] = [
|
||||||
|
$definition->getName(),
|
||||||
|
$candidate['value'],
|
||||||
|
$candidate['sourceDate'],
|
||||||
|
$currentValue ? 'update-empty' : 'create-missing',
|
||||||
|
];
|
||||||
|
$changesCount++;
|
||||||
|
|
||||||
|
if (!$apply) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$currentValue instanceof CustomFieldValue) {
|
||||||
|
$currentValue = new CustomFieldValue();
|
||||||
|
$currentValue->setPiece($piece);
|
||||||
|
$currentValue->setCustomField($definition);
|
||||||
|
$this->em->persist($currentValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentValue->setValue($candidate['value']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (0 === $changesCount) {
|
||||||
|
$io->success('No missing or empty custom field values needed restoration.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($apply) {
|
||||||
|
$this->em->flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
$io->table(
|
||||||
|
['Field', 'Restored value', 'Audit date', 'Action'],
|
||||||
|
$plannedRows,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($apply) {
|
||||||
|
$io->success(sprintf('%d custom field value(s) restored.', $changesCount));
|
||||||
|
} else {
|
||||||
|
$io->note(sprintf(
|
||||||
|
'Dry-run only. Re-run with --apply to persist %d restoration(s).',
|
||||||
|
$changesCount,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<AuditLog> $history
|
||||||
|
*
|
||||||
|
* @return array<string, array{value: string, sourceDate: string, sourceField: string}>
|
||||||
|
*/
|
||||||
|
private function extractHistoricalValues(array $history): array
|
||||||
|
{
|
||||||
|
$values = [];
|
||||||
|
|
||||||
|
foreach ($history as $log) {
|
||||||
|
$diff = $log->getDiff();
|
||||||
|
if (!is_array($diff)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($diff as $field => $change) {
|
||||||
|
if (!is_string($field) || !str_starts_with($field, 'customField:') || !is_array($change)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawName = trim(substr($field, \strlen('customField:')));
|
||||||
|
$normalizedName = $this->normalizeFieldName($rawName);
|
||||||
|
if ('' === $normalizedName || array_key_exists($normalizedName, $values)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = $this->extractCandidateValue($change);
|
||||||
|
if (null === $candidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$values[$normalizedName] = [
|
||||||
|
'value' => $candidate,
|
||||||
|
'sourceDate' => $log->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||||
|
'sourceField'=> $rawName,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($values);
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{from?: mixed, to?: mixed} $change
|
||||||
|
*/
|
||||||
|
private function extractCandidateValue(array $change): ?string
|
||||||
|
{
|
||||||
|
$to = $change['to'] ?? null;
|
||||||
|
if (is_string($to) && '' !== trim($to)) {
|
||||||
|
return $to;
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = $change['from'] ?? null;
|
||||||
|
if (is_string($from) && '' !== trim($from)) {
|
||||||
|
return $from;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, CustomFieldValue>
|
||||||
|
*/
|
||||||
|
private function indexCurrentValues(Collection $customFieldValues): array
|
||||||
|
{
|
||||||
|
$indexed = [];
|
||||||
|
|
||||||
|
foreach ($customFieldValues as $customFieldValue) {
|
||||||
|
if (!$customFieldValue instanceof CustomFieldValue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$indexed[$customFieldValue->getCustomField()->getId()] = $customFieldValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $indexed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeFieldName(string $name): string
|
||||||
|
{
|
||||||
|
$normalized = trim($name);
|
||||||
|
if ('' === $normalized) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
|
||||||
|
if (false !== $transliterated) {
|
||||||
|
$normalized = $transliterated;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtolower($normalized);
|
||||||
|
$normalized = (string) preg_replace('/[^a-z0-9]+/', ' ', $normalized);
|
||||||
|
|
||||||
|
return trim((string) preg_replace('/\s+/', ' ', $normalized));
|
||||||
|
}
|
||||||
|
}
|
||||||
311
src/Command/RestoreRecoverablePieceCustomFieldValuesCommand.php
Normal file
311
src/Command/RestoreRecoverablePieceCustomFieldValuesCommand.php
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\AuditLog;
|
||||||
|
use App\Entity\CustomField;
|
||||||
|
use App\Entity\CustomFieldValue;
|
||||||
|
use App\Entity\ModelType;
|
||||||
|
use App\Entity\Piece;
|
||||||
|
use App\Repository\AuditLogRepository;
|
||||||
|
use App\Repository\PieceRepository;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||||
|
|
||||||
|
use function array_key_exists;
|
||||||
|
use function array_slice;
|
||||||
|
use function count;
|
||||||
|
use function iconv;
|
||||||
|
use function in_array;
|
||||||
|
use function is_array;
|
||||||
|
use function is_string;
|
||||||
|
use function preg_replace;
|
||||||
|
use function sprintf;
|
||||||
|
use function str_starts_with;
|
||||||
|
use function strtolower;
|
||||||
|
use function trim;
|
||||||
|
|
||||||
|
#[AsCommand(
|
||||||
|
name: 'app:restore-recoverable-piece-custom-field-values',
|
||||||
|
description: 'Restore all recoverable missing or empty custom field values for pieces',
|
||||||
|
)]
|
||||||
|
final class RestoreRecoverablePieceCustomFieldValuesCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PieceRepository $pieces,
|
||||||
|
private readonly AuditLogRepository $auditLogs,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void
|
||||||
|
{
|
||||||
|
$this
|
||||||
|
->addOption('apply', null, InputOption::VALUE_NONE, 'Persist restored values instead of dry-run mode')
|
||||||
|
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of audit entries to inspect per piece', '500')
|
||||||
|
->addOption('category', null, InputOption::VALUE_REQUIRED, 'Only process pieces whose ModelType name matches this category')
|
||||||
|
->addOption('piece-id', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Restrict to one or more piece IDs')
|
||||||
|
->addOption('max-rows', null, InputOption::VALUE_REQUIRED, 'Maximum rows displayed in the preview table', '300')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$io = new SymfonyStyle($input, $output);
|
||||||
|
$apply = (bool) $input->getOption('apply');
|
||||||
|
$limit = max(1, (int) $input->getOption('limit'));
|
||||||
|
$maxRows = max(1, (int) $input->getOption('max-rows'));
|
||||||
|
$category = $this->normalizeOptionalString($input->getOption('category'));
|
||||||
|
$pieceIdsRaw = $input->getOption('piece-id');
|
||||||
|
$pieceIds = is_array($pieceIdsRaw) ? array_values(array_filter(array_map('strval', $pieceIdsRaw))) : [];
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
$changesCount = 0;
|
||||||
|
$pieceCount = 0;
|
||||||
|
|
||||||
|
foreach ($this->pieces->findAll() as $piece) {
|
||||||
|
if (!$piece instanceof Piece) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $pieceIds && !in_array((string) $piece->getId(), $pieceIds, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = $piece->getTypePiece();
|
||||||
|
if (!$type instanceof ModelType) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $category && $this->normalizeFieldName($type->getName()) !== $this->normalizeFieldName($category)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pieceRows = $this->restorePiece($piece, $limit, $apply);
|
||||||
|
if ([] === $pieceRows) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pieceCount++;
|
||||||
|
$changesCount += count($pieceRows);
|
||||||
|
$rows = [...$rows, ...$pieceRows];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $rows) {
|
||||||
|
$io->success('No recoverable piece custom field values found.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$displayRows = array_slice($rows, 0, $maxRows);
|
||||||
|
$io->table(
|
||||||
|
['Piece ID', 'Name', 'Reference', 'Category', 'Field', 'Restored value', 'Audit date', 'Action'],
|
||||||
|
$displayRows,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (count($rows) > $maxRows) {
|
||||||
|
$io->warning(sprintf('Output truncated: showing %d of %d row(s).', $maxRows, count($rows)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($apply) {
|
||||||
|
$this->em->flush();
|
||||||
|
$io->success(sprintf('%d value(s) restored across %d piece(s).', $changesCount, $pieceCount));
|
||||||
|
} else {
|
||||||
|
$io->note(sprintf(
|
||||||
|
'Dry-run only. %d value(s) recoverable across %d piece(s). Re-run with --apply to persist.',
|
||||||
|
$changesCount,
|
||||||
|
$pieceCount,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<int, string>>
|
||||||
|
*/
|
||||||
|
private function restorePiece(Piece $piece, int $limit, bool $apply): array
|
||||||
|
{
|
||||||
|
$type = $piece->getTypePiece();
|
||||||
|
if (!$type instanceof ModelType) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$definitions = $type->getPieceCustomFields();
|
||||||
|
if (0 === $definitions->count()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$history = $this->auditLogs->findEntityHistory('piece', (string) $piece->getId(), $limit);
|
||||||
|
if ([] === $history) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$historicalValues = $this->extractHistoricalValues($history);
|
||||||
|
if ([] === $historicalValues) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentValuesByFieldId = $this->indexCurrentValues($piece->getCustomFieldValues());
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($definitions as $definition) {
|
||||||
|
if (!$definition instanceof CustomField) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedName = $this->normalizeFieldName($definition->getName());
|
||||||
|
$candidate = $historicalValues[$normalizedName] ?? null;
|
||||||
|
if (null === $candidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentValue = $currentValuesByFieldId[$definition->getId()] ?? null;
|
||||||
|
$shouldRestore = null === $currentValue || '' === trim($currentValue->getValue());
|
||||||
|
if (!$shouldRestore) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $currentValue instanceof CustomFieldValue ? 'update-empty' : 'create-missing';
|
||||||
|
$rows[] = [
|
||||||
|
(string) $piece->getId(),
|
||||||
|
$piece->getName(),
|
||||||
|
$piece->getReference() ?? '',
|
||||||
|
$type->getName(),
|
||||||
|
$definition->getName(),
|
||||||
|
$candidate['value'],
|
||||||
|
$candidate['sourceDate'],
|
||||||
|
$action,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!$apply) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$currentValue instanceof CustomFieldValue) {
|
||||||
|
$currentValue = new CustomFieldValue();
|
||||||
|
$currentValue->setPiece($piece);
|
||||||
|
$currentValue->setCustomField($definition);
|
||||||
|
$this->em->persist($currentValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentValue->setValue($candidate['value']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<AuditLog> $history
|
||||||
|
*
|
||||||
|
* @return array<string, array{value: string, sourceDate: string}>
|
||||||
|
*/
|
||||||
|
private function extractHistoricalValues(array $history): array
|
||||||
|
{
|
||||||
|
$values = [];
|
||||||
|
|
||||||
|
foreach ($history as $log) {
|
||||||
|
$diff = $log->getDiff();
|
||||||
|
if (!is_array($diff)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($diff as $field => $change) {
|
||||||
|
if (!is_string($field) || !str_starts_with($field, 'customField:') || !is_array($change)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedName = $this->normalizeFieldName(trim(substr($field, 12)));
|
||||||
|
if ('' === $normalizedName || array_key_exists($normalizedName, $values)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidate = $this->extractCandidateValue($change);
|
||||||
|
if (null === $candidate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$values[$normalizedName] = [
|
||||||
|
'value' => $candidate,
|
||||||
|
'sourceDate' => $log->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{from?: mixed, to?: mixed} $change
|
||||||
|
*/
|
||||||
|
private function extractCandidateValue(array $change): ?string
|
||||||
|
{
|
||||||
|
$to = $change['to'] ?? null;
|
||||||
|
if (is_string($to) && '' !== trim($to)) {
|
||||||
|
return $to;
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = $change['from'] ?? null;
|
||||||
|
if (is_string($from) && '' !== trim($from)) {
|
||||||
|
return $from;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, CustomFieldValue>
|
||||||
|
*/
|
||||||
|
private function indexCurrentValues(Collection $customFieldValues): array
|
||||||
|
{
|
||||||
|
$indexed = [];
|
||||||
|
|
||||||
|
foreach ($customFieldValues as $customFieldValue) {
|
||||||
|
if (!$customFieldValue instanceof CustomFieldValue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$indexed[$customFieldValue->getCustomField()->getId()] = $customFieldValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $indexed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeFieldName(string $name): string
|
||||||
|
{
|
||||||
|
$normalized = trim($name);
|
||||||
|
if ('' === $normalized) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$transliterated = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized);
|
||||||
|
if (false !== $transliterated) {
|
||||||
|
$normalized = $transliterated;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = strtolower($normalized);
|
||||||
|
$normalized = (string) preg_replace('/[^a-z0-9]+/', ' ', $normalized);
|
||||||
|
|
||||||
|
return trim((string) preg_replace('/\s+/', ' ', $normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeOptionalString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (!is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trimmed = trim($value);
|
||||||
|
|
||||||
|
return '' === $trimmed ? null : $trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,11 +5,15 @@ declare(strict_types=1);
|
|||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
use App\Entity\Comment;
|
use App\Entity\Comment;
|
||||||
|
use App\Entity\Document;
|
||||||
|
use App\Enum\DocumentType;
|
||||||
use App\Repository\ProfileRepository;
|
use App\Repository\ProfileRepository;
|
||||||
|
use App\Service\DocumentStorageService;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -20,6 +24,7 @@ final class CommentController extends AbstractController
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly EntityManagerInterface $entityManager,
|
private readonly EntityManagerInterface $entityManager,
|
||||||
private readonly ProfileRepository $profiles,
|
private readonly ProfileRepository $profiles,
|
||||||
|
private readonly DocumentStorageService $storageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
#[Route('', name: 'api_comments_create', methods: ['POST'])]
|
#[Route('', name: 'api_comments_create', methods: ['POST'])]
|
||||||
@@ -38,16 +43,25 @@ final class CommentController extends AbstractController
|
|||||||
return $this->json(['message' => 'Profil introuvable.'], 401);
|
return $this->json(['message' => 'Profil introuvable.'], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
$payload = json_decode($request->getContent(), true);
|
// Parse fields from JSON or form-data
|
||||||
if (!is_array($payload)) {
|
$contentType = $request->headers->get('Content-Type', '');
|
||||||
return $this->json(['message' => 'Payload JSON invalide.'], 400);
|
$isFormData = str_contains($contentType, 'multipart/form-data') || $request->files->count() > 0 || $request->request->has('content');
|
||||||
|
if ($isFormData) {
|
||||||
|
$content = trim((string) $request->request->get('content', ''));
|
||||||
|
$entityType = trim((string) $request->request->get('entityType', ''));
|
||||||
|
$entityId = trim((string) $request->request->get('entityId', ''));
|
||||||
|
$entityName = $request->request->get('entityName') ? trim((string) $request->request->get('entityName')) : null;
|
||||||
|
} else {
|
||||||
|
$payload = json_decode($request->getContent(), true);
|
||||||
|
if (!is_array($payload)) {
|
||||||
|
return $this->json(['message' => 'Payload JSON invalide.'], 400);
|
||||||
|
}
|
||||||
|
$content = trim((string) ($payload['content'] ?? ''));
|
||||||
|
$entityType = trim((string) ($payload['entityType'] ?? ''));
|
||||||
|
$entityId = trim((string) ($payload['entityId'] ?? ''));
|
||||||
|
$entityName = isset($payload['entityName']) ? trim((string) $payload['entityName']) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$content = trim((string) ($payload['content'] ?? ''));
|
|
||||||
$entityType = trim((string) ($payload['entityType'] ?? ''));
|
|
||||||
$entityId = trim((string) ($payload['entityId'] ?? ''));
|
|
||||||
$entityName = isset($payload['entityName']) ? trim((string) $payload['entityName']) : null;
|
|
||||||
|
|
||||||
if ('' === $content) {
|
if ('' === $content) {
|
||||||
return $this->json(['message' => 'Le contenu est requis.'], 400);
|
return $this->json(['message' => 'Le contenu est requis.'], 400);
|
||||||
}
|
}
|
||||||
@@ -75,6 +89,36 @@ final class CommentController extends AbstractController
|
|||||||
$comment->setAuthorName($authorName);
|
$comment->setAuthorName($authorName);
|
||||||
|
|
||||||
$this->entityManager->persist($comment);
|
$this->entityManager->persist($comment);
|
||||||
|
|
||||||
|
// Handle file uploads
|
||||||
|
$files = $request->files->all('files');
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (!$file instanceof UploadedFile || !$file->isValid()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$document = new Document();
|
||||||
|
$documentId = 'cl'.bin2hex(random_bytes(12));
|
||||||
|
$document->setId($documentId);
|
||||||
|
$document->setName($file->getClientOriginalName());
|
||||||
|
$document->setFilename($file->getClientOriginalName());
|
||||||
|
$document->setMimeType($file->getMimeType() ?: 'application/octet-stream');
|
||||||
|
$document->setSize((int) $file->getSize());
|
||||||
|
$document->setType(DocumentType::DOCUMENTATION);
|
||||||
|
$document->setComment($comment);
|
||||||
|
$comment->getDocuments()->add($document);
|
||||||
|
|
||||||
|
$extension = $this->storageService->extensionFromFilename($file->getClientOriginalName());
|
||||||
|
$relativePath = $this->storageService->storeFromPath(
|
||||||
|
$file->getPathname(),
|
||||||
|
$documentId,
|
||||||
|
$extension,
|
||||||
|
);
|
||||||
|
$document->setPath($relativePath);
|
||||||
|
|
||||||
|
$this->entityManager->persist($document);
|
||||||
|
}
|
||||||
|
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
return $this->json($this->normalize($comment), 201);
|
return $this->json($this->normalize($comment), 201);
|
||||||
@@ -112,6 +156,76 @@ final class CommentController extends AbstractController
|
|||||||
return $this->json($this->normalize($comment));
|
return $this->json($this->normalize($comment));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/search/list', name: 'api_comments_list', methods: ['GET'])]
|
||||||
|
public function list(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||||
|
|
||||||
|
$qb = $this->entityManager->getRepository(Comment::class)->createQueryBuilder('c');
|
||||||
|
|
||||||
|
$status = $request->query->get('status');
|
||||||
|
if ($status) {
|
||||||
|
$qb->andWhere('c.status = :status')->setParameter('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityType = $request->query->get('entityType');
|
||||||
|
if ($entityType) {
|
||||||
|
$qb->andWhere('c.entityType = :entityType')->setParameter('entityType', $entityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
$entityName = $request->query->get('entityName');
|
||||||
|
if ($entityName) {
|
||||||
|
$qb->andWhere('LOWER(c.entityName) LIKE LOWER(:entityName)')->setParameter('entityName', '%'.$entityName.'%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count total before pagination
|
||||||
|
$countQb = clone $qb;
|
||||||
|
$total = (int) $countQb->select('COUNT(c.id)')->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
$sortField = $request->query->get('sort', 'createdAt');
|
||||||
|
$sortDir = strtoupper($request->query->get('direction', 'DESC'));
|
||||||
|
$allowedSortFields = ['createdAt', 'authorName', 'status'];
|
||||||
|
if (!in_array($sortField, $allowedSortFields, true)) {
|
||||||
|
$sortField = 'createdAt';
|
||||||
|
}
|
||||||
|
if (!in_array($sortDir, ['ASC', 'DESC'], true)) {
|
||||||
|
$sortDir = 'DESC';
|
||||||
|
}
|
||||||
|
$qb->orderBy('c.'.$sortField, $sortDir);
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
$itemsPerPage = min((int) $request->query->get('itemsPerPage', '30'), 200);
|
||||||
|
$page = max((int) $request->query->get('page', '1'), 1);
|
||||||
|
$qb->setMaxResults($itemsPerPage)->setFirstResult(($page - 1) * $itemsPerPage);
|
||||||
|
|
||||||
|
$comments = $qb->getQuery()->getResult();
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'items' => array_map(fn (Comment $c) => $this->normalize($c), $comments),
|
||||||
|
'total' => $total,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/by-entity/{entityType}/{entityId}', name: 'api_comments_by_entity', methods: ['GET'])]
|
||||||
|
public function listByEntity(string $entityType, string $entityId, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||||
|
|
||||||
|
$criteria = ['entityType' => $entityType, 'entityId' => $entityId];
|
||||||
|
|
||||||
|
$status = $request->query->get('status');
|
||||||
|
if ($status) {
|
||||||
|
$criteria['status'] = $status;
|
||||||
|
}
|
||||||
|
|
||||||
|
$comments = $this->entityManager->getRepository(Comment::class)
|
||||||
|
->findBy($criteria, ['createdAt' => 'DESC'])
|
||||||
|
;
|
||||||
|
|
||||||
|
return $this->json(array_map(fn (Comment $c) => $this->normalize($c), $comments));
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/stats/unresolved-count', name: 'api_comments_unresolved_count', methods: ['GET'])]
|
#[Route('/stats/unresolved-count', name: 'api_comments_unresolved_count', methods: ['GET'])]
|
||||||
public function unresolvedCount(): JsonResponse
|
public function unresolvedCount(): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -126,6 +240,21 @@ final class CommentController extends AbstractController
|
|||||||
|
|
||||||
private function normalize(Comment $comment): array
|
private function normalize(Comment $comment): array
|
||||||
{
|
{
|
||||||
|
$documents = [];
|
||||||
|
foreach ($comment->getDocuments() as $document) {
|
||||||
|
$documents[] = [
|
||||||
|
'id' => $document->getId(),
|
||||||
|
'name' => $document->getName(),
|
||||||
|
'filename' => $document->getFilename(),
|
||||||
|
'mimeType' => $document->getMimeType(),
|
||||||
|
'size' => $document->getSize(),
|
||||||
|
'type' => $document->getType()->value,
|
||||||
|
'fileUrl' => '/api/documents/'.$document->getId().'/file',
|
||||||
|
'downloadUrl' => '/api/documents/'.$document->getId().'/download',
|
||||||
|
'createdAt' => $document->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $comment->getId(),
|
'id' => $comment->getId(),
|
||||||
'content' => $comment->getContent(),
|
'content' => $comment->getContent(),
|
||||||
@@ -140,6 +269,7 @@ final class CommentController extends AbstractController
|
|||||||
'resolvedAt' => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM),
|
'resolvedAt' => $comment->getResolvedAt()?->format(DateTimeInterface::ATOM),
|
||||||
'createdAt' => $comment->getCreatedAt()->format(DateTimeInterface::ATOM),
|
'createdAt' => $comment->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||||
'updatedAt' => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM),
|
'updatedAt' => $comment->getUpdatedAt()->format(DateTimeInterface::ATOM),
|
||||||
|
'documents' => $documents,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ class ComposantPieceSlotController extends AbstractController
|
|||||||
$slot->setSelectedPiece(null);
|
$slot->setSelectedPiece(null);
|
||||||
} else {
|
} else {
|
||||||
$piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']);
|
$piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']);
|
||||||
|
if (!$piece) {
|
||||||
|
return $this->json(['success' => false, 'error' => 'Pièce introuvable.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$slotTypePiece = $slot->getTypePiece();
|
||||||
|
if ($slotTypePiece && $piece->getTypePiece()?->getId() !== $slotTypePiece->getId()) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => sprintf('La pièce doit être de type « %s ».', $slotTypePiece->getName()),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
$slot->setSelectedPiece($piece);
|
$slot->setSelectedPiece($piece);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,19 +196,16 @@ class CustomFieldValueController extends AbstractController
|
|||||||
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
|
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$customField = new CustomField();
|
// Try to find an existing custom field by name instead of creating an orphan
|
||||||
$customField->setName($customFieldName);
|
$existing = $this->customFieldRepository->findOneBy(['name' => $customFieldName]);
|
||||||
$customField->setType((string) ($payload['customFieldType'] ?? 'text'));
|
if ($existing instanceof CustomField) {
|
||||||
$customField->setRequired((bool) ($payload['customFieldRequired'] ?? false));
|
return $existing;
|
||||||
|
|
||||||
$options = $payload['customFieldOptions'] ?? null;
|
|
||||||
if (is_array($options)) {
|
|
||||||
$customField->setOptions($options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->entityManager->persist($customField);
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
return $customField;
|
'error' => sprintf('Custom field "%s" not found. Create it explicitly first.', $customFieldName),
|
||||||
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveTarget(array $payload): array|JsonResponse
|
private function resolveTarget(array $payload): array|JsonResponse
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Controller;
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Comment;
|
||||||
use App\Entity\Document;
|
use App\Entity\Document;
|
||||||
use App\Repository\ComposantRepository;
|
use App\Repository\ComposantRepository;
|
||||||
use App\Repository\DocumentRepository;
|
use App\Repository\DocumentRepository;
|
||||||
@@ -11,6 +12,7 @@ use App\Repository\MachineRepository;
|
|||||||
use App\Repository\PieceRepository;
|
use App\Repository\PieceRepository;
|
||||||
use App\Repository\ProductRepository;
|
use App\Repository\ProductRepository;
|
||||||
use App\Repository\SiteRepository;
|
use App\Repository\SiteRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
@@ -25,6 +27,7 @@ class DocumentQueryController extends AbstractController
|
|||||||
private readonly ComposantRepository $composantRepository,
|
private readonly ComposantRepository $composantRepository,
|
||||||
private readonly PieceRepository $pieceRepository,
|
private readonly PieceRepository $pieceRepository,
|
||||||
private readonly ProductRepository $productRepository,
|
private readonly ProductRepository $productRepository,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
#[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])]
|
#[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])]
|
||||||
@@ -102,6 +105,21 @@ class DocumentQueryController extends AbstractController
|
|||||||
return $this->json($this->normalizeDocuments($documents));
|
return $this->json($this->normalizeDocuments($documents));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Route('/comment/{id}', name: 'documents_by_comment', methods: ['GET'])]
|
||||||
|
public function listByComment(string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('ROLE_VIEWER');
|
||||||
|
|
||||||
|
$comment = $this->em->find(Comment::class, $id);
|
||||||
|
if (!$comment) {
|
||||||
|
return $this->json(['success' => false, 'error' => 'Comment not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$documents = $this->documentRepository->findBy(['comment' => $comment]);
|
||||||
|
|
||||||
|
return $this->json($this->normalizeDocuments($documents));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Document[] $documents
|
* @param Document[] $documents
|
||||||
*/
|
*/
|
||||||
@@ -121,6 +139,8 @@ class DocumentQueryController extends AbstractController
|
|||||||
'composantId' => $document->getComposant()?->getId(),
|
'composantId' => $document->getComposant()?->getId(),
|
||||||
'pieceId' => $document->getPiece()?->getId(),
|
'pieceId' => $document->getPiece()?->getId(),
|
||||||
'productId' => $document->getProduct()?->getId(),
|
'productId' => $document->getProduct()?->getId(),
|
||||||
|
'commentId' => $document->getComment()?->getId(),
|
||||||
|
'type' => $document->getType()->value,
|
||||||
'createdAt' => $document->getCreatedAt()->format(DATE_ATOM),
|
'createdAt' => $document->getCreatedAt()->format(DATE_ATOM),
|
||||||
'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM),
|
'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ class MachineStructureController extends AbstractController
|
|||||||
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
|
return $this->json(['success' => false, 'error' => 'Machine not found.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine]);
|
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
|
||||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
|
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
|
||||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
|
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
|
||||||
|
|
||||||
return $this->json($this->normalizeStructureResponse(
|
return $this->json($this->normalizeStructureResponse(
|
||||||
$machine,
|
$machine,
|
||||||
@@ -159,9 +159,9 @@ class MachineStructureController extends AbstractController
|
|||||||
|
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine]);
|
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
|
||||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $newMachine]);
|
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
|
||||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $newMachine]);
|
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
|
||||||
|
|
||||||
return $this->json($this->normalizeStructureResponse(
|
return $this->json($this->normalizeStructureResponse(
|
||||||
$newMachine,
|
$newMachine,
|
||||||
@@ -173,6 +173,8 @@ class MachineStructureController extends AbstractController
|
|||||||
|
|
||||||
private function cloneCustomFields(Machine $source, Machine $target): void
|
private function cloneCustomFields(Machine $source, Machine $target): void
|
||||||
{
|
{
|
||||||
|
$cfMap = [];
|
||||||
|
|
||||||
foreach ($source->getCustomFields() as $cf) {
|
foreach ($source->getCustomFields() as $cf) {
|
||||||
$newCf = new CustomField();
|
$newCf = new CustomField();
|
||||||
$newCf->setName($cf->getName());
|
$newCf->setName($cf->getName());
|
||||||
@@ -183,12 +185,20 @@ class MachineStructureController extends AbstractController
|
|||||||
$newCf->setOrderIndex($cf->getOrderIndex());
|
$newCf->setOrderIndex($cf->getOrderIndex());
|
||||||
$newCf->setMachine($target);
|
$newCf->setMachine($target);
|
||||||
$this->entityManager->persist($newCf);
|
$this->entityManager->persist($newCf);
|
||||||
|
|
||||||
|
$cfMap[$cf->getId()] = $newCf;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($source->getCustomFieldValues() as $cfv) {
|
foreach ($source->getCustomFieldValues() as $cfv) {
|
||||||
|
$originalCf = $cfv->getCustomField();
|
||||||
|
$newCf = $cfMap[$originalCf->getId()] ?? null;
|
||||||
|
if (!$newCf) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$newValue = new CustomFieldValue();
|
$newValue = new CustomFieldValue();
|
||||||
$newValue->setMachine($target);
|
$newValue->setMachine($target);
|
||||||
$newValue->setCustomField($cfv->getCustomField());
|
$newValue->setCustomField($newCf);
|
||||||
$newValue->setValue($cfv->getValue());
|
$newValue->setValue($cfv->getValue());
|
||||||
$this->entityManager->persist($newValue);
|
$this->entityManager->persist($newValue);
|
||||||
}
|
}
|
||||||
@@ -199,7 +209,7 @@ class MachineStructureController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
private function cloneComponentLinks(Machine $source, Machine $target): array
|
private function cloneComponentLinks(Machine $source, Machine $target): array
|
||||||
{
|
{
|
||||||
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source]);
|
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||||
$linkMap = [];
|
$linkMap = [];
|
||||||
|
|
||||||
// First pass: create all links without parent relationships
|
// First pass: create all links without parent relationships
|
||||||
@@ -232,7 +242,7 @@ class MachineStructureController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array
|
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array
|
||||||
{
|
{
|
||||||
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source]);
|
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||||
$linkMap = [];
|
$linkMap = [];
|
||||||
|
|
||||||
foreach ($sourceLinks as $link) {
|
foreach ($sourceLinks as $link) {
|
||||||
@@ -266,7 +276,7 @@ class MachineStructureController extends AbstractController
|
|||||||
array $componentLinkMap,
|
array $componentLinkMap,
|
||||||
array $pieceLinkMap,
|
array $pieceLinkMap,
|
||||||
): void {
|
): void {
|
||||||
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source]);
|
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||||
$linkMap = [];
|
$linkMap = [];
|
||||||
|
|
||||||
// First pass: create all links
|
// First pass: create all links
|
||||||
@@ -309,7 +319,7 @@ class MachineStructureController extends AbstractController
|
|||||||
|
|
||||||
private function applyComponentLinks(Machine $machine, array $payload): array|JsonResponse
|
private function applyComponentLinks(Machine $machine, array $payload): array|JsonResponse
|
||||||
{
|
{
|
||||||
$existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine]));
|
$existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']));
|
||||||
$keepIds = [];
|
$keepIds = [];
|
||||||
$pendingParents = [];
|
$pendingParents = [];
|
||||||
$links = [];
|
$links = [];
|
||||||
@@ -366,7 +376,7 @@ class MachineStructureController extends AbstractController
|
|||||||
|
|
||||||
private function applyPieceLinks(Machine $machine, array $payload, array $componentLinks): array|JsonResponse
|
private function applyPieceLinks(Machine $machine, array $payload, array $componentLinks): array|JsonResponse
|
||||||
{
|
{
|
||||||
$existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine]));
|
$existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']));
|
||||||
$componentIndex = $this->indexLinksById($componentLinks);
|
$componentIndex = $this->indexLinksById($componentLinks);
|
||||||
$keepIds = [];
|
$keepIds = [];
|
||||||
$pendingParents = [];
|
$pendingParents = [];
|
||||||
@@ -433,7 +443,7 @@ class MachineStructureController extends AbstractController
|
|||||||
array $componentLinks,
|
array $componentLinks,
|
||||||
array $pieceLinks,
|
array $pieceLinks,
|
||||||
): array|JsonResponse {
|
): array|JsonResponse {
|
||||||
$existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine]));
|
$existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']));
|
||||||
$componentIndex = $this->indexLinksById($componentLinks);
|
$componentIndex = $this->indexLinksById($componentLinks);
|
||||||
$pieceIndex = $this->indexLinksById($pieceLinks);
|
$pieceIndex = $this->indexLinksById($pieceLinks);
|
||||||
$keepIds = [];
|
$keepIds = [];
|
||||||
|
|||||||
61
src/Doctrine/SearchByNameOrReferenceExtension.php
Normal file
61
src/Doctrine/SearchByNameOrReferenceExtension.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Doctrine;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use App\Entity\Composant;
|
||||||
|
use App\Entity\Piece;
|
||||||
|
use App\Entity\Product;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
|
||||||
|
use function in_array;
|
||||||
|
use function is_string;
|
||||||
|
|
||||||
|
final class SearchByNameOrReferenceExtension implements QueryCollectionExtensionInterface
|
||||||
|
{
|
||||||
|
private const SUPPORTED_CLASSES = [
|
||||||
|
Piece::class,
|
||||||
|
Composant::class,
|
||||||
|
Product::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function applyToCollection(
|
||||||
|
QueryBuilder $queryBuilder,
|
||||||
|
QueryNameGeneratorInterface $queryNameGenerator,
|
||||||
|
string $resourceClass,
|
||||||
|
?Operation $operation = null,
|
||||||
|
array $context = [],
|
||||||
|
): void {
|
||||||
|
if (!in_array($resourceClass, self::SUPPORTED_CLASSES, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
if (null === $request) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$q = $request->query->get('q', '');
|
||||||
|
if (!is_string($q) || '' === trim($q)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$escaped = addcslashes(trim($q), '%_');
|
||||||
|
$paramName = $queryNameGenerator->generateParameterName('searchQ');
|
||||||
|
$alias = $queryBuilder->getRootAliases()[0];
|
||||||
|
|
||||||
|
$queryBuilder
|
||||||
|
->andWhere(sprintf('LOWER(%s.name) LIKE :%s OR LOWER(%s.reference) LIKE :%s', $alias, $paramName, $alias, $paramName))
|
||||||
|
->setParameter($paramName, '%'.strtolower($escaped).'%')
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ use ApiPlatform\Metadata\GetCollection;
|
|||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
use App\Entity\Trait\CuidEntityTrait;
|
use App\Entity\Trait\CuidEntityTrait;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
@@ -79,10 +81,15 @@ class Comment
|
|||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')]
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updated_at')]
|
||||||
private DateTimeImmutable $updatedAt;
|
private DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
/** @var Collection<int, Document> */
|
||||||
|
#[ORM\OneToMany(targetEntity: Document::class, mappedBy: 'comment', cascade: ['remove'])]
|
||||||
|
private Collection $documents;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->createdAt = new DateTimeImmutable();
|
$this->createdAt = new DateTimeImmutable();
|
||||||
$this->updatedAt = new DateTimeImmutable();
|
$this->updatedAt = new DateTimeImmutable();
|
||||||
|
$this->documents = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getContent(): string
|
public function getContent(): string
|
||||||
@@ -204,4 +211,10 @@ class Comment
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, Document> */
|
||||||
|
public function getDocuments(): Collection
|
||||||
|
{
|
||||||
|
return $this->documents;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ use ApiPlatform\Metadata\Patch;
|
|||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use ApiPlatform\Metadata\Put;
|
use ApiPlatform\Metadata\Put;
|
||||||
use App\Entity\Trait\CuidEntityTrait;
|
use App\Entity\Trait\CuidEntityTrait;
|
||||||
|
use App\Filter\MultiSearchFilter;
|
||||||
use App\Repository\ComposantRepository;
|
use App\Repository\ComposantRepository;
|
||||||
|
use App\State\ComposantProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
@@ -27,13 +29,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
#[ORM\Table(name: 'composants')]
|
#[ORM\Table(name: 'composants')]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
#[ORM\HasLifecycleCallbacks]
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact', 'typeComposant.name' => 'ipartial'])]
|
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeComposant' => 'exact', 'typeComposant.name' => 'ipartial'])]
|
||||||
|
#[ApiFilter(MultiSearchFilter::class, properties: ['name', 'reference'])]
|
||||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
|
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
description: 'Composants du catalogue. Un composant représente un élément fonctionnel rattaché à une machine, avec un type, des fournisseurs et des documents.',
|
description: 'Composants du catalogue. Un composant représente un élément fonctionnel rattaché à une machine, avec un type, des fournisseurs et des documents.',
|
||||||
operations: [
|
operations: [
|
||||||
new Get(security: "is_granted('ROLE_VIEWER')"),
|
new Get(security: "is_granted('ROLE_VIEWER')"),
|
||||||
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
|
||||||
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Post(security: "is_granted('ROLE_GESTIONNAIRE')", processor: ComposantProcessor::class),
|
||||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
@@ -130,6 +133,12 @@ class Composant
|
|||||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||||
private Collection $productSlots;
|
private Collection $productSlots;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transient — holds the structure payload sent by the frontend during creation.
|
||||||
|
* Not mapped to any column; consumed by ComposantProcessor.
|
||||||
|
*/
|
||||||
|
private ?array $pendingStructure = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||||
#[Groups(['composant:read'])]
|
#[Groups(['composant:read'])]
|
||||||
private int $version = 1;
|
private int $version = 1;
|
||||||
@@ -394,6 +403,27 @@ class Composant
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by API Platform during denormalization — stores the frontend
|
||||||
|
* structure payload so the ComposantProcessor can apply selections.
|
||||||
|
*/
|
||||||
|
public function setStructure(?array $structure): static
|
||||||
|
{
|
||||||
|
$this->pendingStructure = $structure;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPendingStructure(): ?array
|
||||||
|
{
|
||||||
|
return $this->pendingStructure;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearPendingStructure(): void
|
||||||
|
{
|
||||||
|
$this->pendingStructure = null;
|
||||||
|
}
|
||||||
|
|
||||||
public function addProductSlot(ComposantProductSlot $productSlot): static
|
public function addProductSlot(ComposantProductSlot $productSlot): static
|
||||||
{
|
{
|
||||||
if (!$this->productSlots->contains($productSlot)) {
|
if (!$this->productSlots->contains($productSlot)) {
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ use ApiPlatform\Metadata\ApiResource;
|
|||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Patch;
|
||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use ApiPlatform\Metadata\Put;
|
use ApiPlatform\Metadata\Put;
|
||||||
use App\Entity\Trait\CuidEntityTrait;
|
use App\Entity\Trait\CuidEntityTrait;
|
||||||
|
use App\Enum\DocumentType;
|
||||||
use App\Repository\DocumentRepository;
|
use App\Repository\DocumentRepository;
|
||||||
use App\State\DocumentUploadProcessor;
|
use App\State\DocumentUploadProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -26,7 +28,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
#[ORM\Table(name: 'documents')]
|
#[ORM\Table(name: 'documents')]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
#[ORM\HasLifecycleCallbacks]
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'filename' => 'ipartial'])]
|
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'filename' => 'ipartial'])]
|
||||||
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product'])]
|
#[ApiFilter(ExistsFilter::class, properties: ['site', 'machine', 'composant', 'piece', 'product', 'comment'])]
|
||||||
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])]
|
#[ApiFilter(OrderFilter::class, properties: ['createdAt', 'name', 'size'])]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
description: 'Documents et fichiers. Gestion des fichiers joints (PDF, images, etc.) rattachés aux machines, pièces, composants, produits ou sites. Upload via multipart/form-data.',
|
description: 'Documents et fichiers. Gestion des fichiers joints (PDF, images, etc.) rattachés aux machines, pièces, composants, produits ou sites. Upload via multipart/form-data.',
|
||||||
@@ -46,6 +48,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
inputFormats: ['multipart' => ['multipart/form-data']],
|
inputFormats: ['multipart' => ['multipart/form-data']],
|
||||||
),
|
),
|
||||||
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
|
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
],
|
],
|
||||||
paginationClientItemsPerPage: true,
|
paginationClientItemsPerPage: true,
|
||||||
@@ -105,6 +108,15 @@ class Document
|
|||||||
#[Groups(['document:list'])]
|
#[Groups(['document:list'])]
|
||||||
private ?Site $site = null;
|
private ?Site $site = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Comment::class, inversedBy: 'documents')]
|
||||||
|
#[ORM\JoinColumn(name: 'comment_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||||
|
#[Groups(['document:list'])]
|
||||||
|
private ?Comment $comment = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::STRING, length: 20, enumType: DocumentType::class)]
|
||||||
|
#[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
|
||||||
|
private DocumentType $type = DocumentType::DOCUMENTATION;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||||
#[Groups(['document:list'])]
|
#[Groups(['document:list'])]
|
||||||
private DateTimeImmutable $createdAt;
|
private DateTimeImmutable $createdAt;
|
||||||
@@ -237,4 +249,28 @@ class Document
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getType(): DocumentType
|
||||||
|
{
|
||||||
|
return $this->type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setType(DocumentType $type): static
|
||||||
|
{
|
||||||
|
$this->type = $type;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getComment(): ?Comment
|
||||||
|
{
|
||||||
|
return $this->comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setComment(?Comment $comment): static
|
||||||
|
{
|
||||||
|
$this->comment = $comment;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
|
|||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use ApiPlatform\Metadata\Put;
|
use ApiPlatform\Metadata\Put;
|
||||||
use App\Entity\Trait\CuidEntityTrait;
|
use App\Entity\Trait\CuidEntityTrait;
|
||||||
|
use App\Filter\MultiSearchFilter;
|
||||||
use App\Repository\PieceRepository;
|
use App\Repository\PieceRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
@@ -29,6 +30,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
#[ORM\Table(name: 'pieces')]
|
#[ORM\Table(name: 'pieces')]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
#[ORM\HasLifecycleCallbacks]
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact', 'typePiece.name' => 'ipartial'])]
|
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typePiece' => 'exact', 'typePiece.name' => 'ipartial'])]
|
||||||
|
#[ApiFilter(MultiSearchFilter::class, properties: ['name', 'reference'])]
|
||||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
|
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt'])]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
description: 'Pièces détachées du catalogue. Une pièce peut être rattachée à plusieurs machines et possède un type, des fournisseurs, des documents et un produit associé.',
|
description: 'Pièces détachées du catalogue. Une pièce peut être rattachée à plusieurs machines et possède un type, des fournisseurs, des documents et un produit associé.',
|
||||||
@@ -244,6 +246,22 @@ class Piece
|
|||||||
return $this->constructeurs;
|
return $this->constructeurs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<Constructeur> $constructeurs
|
||||||
|
*/
|
||||||
|
public function setConstructeurs(iterable $constructeurs): static
|
||||||
|
{
|
||||||
|
$this->constructeurs = new ArrayCollection();
|
||||||
|
|
||||||
|
foreach ($constructeurs as $constructeur) {
|
||||||
|
if ($constructeur instanceof Constructeur && !$this->constructeurs->contains($constructeur)) {
|
||||||
|
$this->constructeurs->add($constructeur);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function addConstructeur(Constructeur $constructeur): static
|
public function addConstructeur(Constructeur $constructeur): static
|
||||||
{
|
{
|
||||||
if (!$this->constructeurs->contains($constructeur)) {
|
if (!$this->constructeurs->contains($constructeur)) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
|
|||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use ApiPlatform\Metadata\Put;
|
use ApiPlatform\Metadata\Put;
|
||||||
use App\Entity\Trait\CuidEntityTrait;
|
use App\Entity\Trait\CuidEntityTrait;
|
||||||
|
use App\Filter\MultiSearchFilter;
|
||||||
use App\Repository\ProductRepository;
|
use App\Repository\ProductRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
@@ -27,6 +28,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
#[ORM\Table(name: 'products')]
|
#[ORM\Table(name: 'products')]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
#[ORM\HasLifecycleCallbacks]
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact', 'typeProduct.name' => 'ipartial'])]
|
#[ApiFilter(SearchFilter::class, properties: ['name' => 'ipartial', 'reference' => 'ipartial', 'typeProduct' => 'exact', 'typeProduct.name' => 'ipartial'])]
|
||||||
|
#[ApiFilter(MultiSearchFilter::class, properties: ['name', 'reference'])]
|
||||||
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt', 'supplierPrice'])]
|
#[ApiFilter(OrderFilter::class, properties: ['name', 'createdAt', 'supplierPrice'])]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
description: 'Produits du catalogue fournisseur. Un produit possède une référence, un prix indicatif, un type, des fournisseurs et des documents. Il peut être lié à des machines.',
|
description: 'Produits du catalogue fournisseur. Un produit possède une référence, un prix indicatif, un type, des fournisseurs et des documents. Il peut être lié à des machines.',
|
||||||
@@ -199,6 +201,22 @@ class Product
|
|||||||
return $this->constructeurs;
|
return $this->constructeurs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param iterable<Constructeur> $constructeurs
|
||||||
|
*/
|
||||||
|
public function setConstructeurs(iterable $constructeurs): static
|
||||||
|
{
|
||||||
|
$this->constructeurs = new ArrayCollection();
|
||||||
|
|
||||||
|
foreach ($constructeurs as $constructeur) {
|
||||||
|
if ($constructeur instanceof Constructeur && !$this->constructeurs->contains($constructeur)) {
|
||||||
|
$this->constructeurs->add($constructeur);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function addConstructeur(Constructeur $constructeur): static
|
public function addConstructeur(Constructeur $constructeur): static
|
||||||
{
|
{
|
||||||
if (!$this->constructeurs->contains($constructeur)) {
|
if (!$this->constructeurs->contains($constructeur)) {
|
||||||
|
|||||||
15
src/Enum/DocumentType.php
Normal file
15
src/Enum/DocumentType.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enum;
|
||||||
|
|
||||||
|
enum DocumentType: string
|
||||||
|
{
|
||||||
|
case DOCUMENTATION = 'documentation';
|
||||||
|
case DEVIS = 'devis';
|
||||||
|
case FACTURE = 'facture';
|
||||||
|
case PLAN = 'plan';
|
||||||
|
case PHOTO = 'photo';
|
||||||
|
case AUTRE = 'autre';
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
|
|||||||
public static function getSubscribedEvents(): array
|
public static function getSubscribedEvents(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
KernelEvents::EXCEPTION => 'onKernelException',
|
KernelEvents::EXCEPTION => ['onKernelException', 256],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,10 +28,17 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$constraint = $this->detectConstraintName($exception);
|
||||||
|
$error = match ($constraint) {
|
||||||
|
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
|
||||||
|
default => 'Un élément avec cette valeur existe déjà.',
|
||||||
|
};
|
||||||
|
|
||||||
$event->setResponse(new JsonResponse(
|
$event->setResponse(new JsonResponse(
|
||||||
[
|
[
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'nom duplique',
|
'error' => $error,
|
||||||
|
'constraint' => $constraint,
|
||||||
],
|
],
|
||||||
JsonResponse::HTTP_CONFLICT
|
JsonResponse::HTTP_CONFLICT
|
||||||
));
|
));
|
||||||
@@ -47,4 +54,15 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function detectConstraintName(UniqueConstraintViolationException $exception): ?string
|
||||||
|
{
|
||||||
|
$message = $exception->getMessage();
|
||||||
|
|
||||||
|
if (preg_match('/constraint\s+"([^"]+)"/', $message, $matches)) {
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/Filter/MultiSearchFilter.php
Normal file
51
src/Filter/MultiSearchFilter.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filter;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
|
||||||
|
final class MultiSearchFilter extends AbstractFilter
|
||||||
|
{
|
||||||
|
public function getDescription(string $resourceClass): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'search' => [
|
||||||
|
'property' => null,
|
||||||
|
'type' => 'string',
|
||||||
|
'required' => false,
|
||||||
|
'description' => 'Search across: '.implode(', ', array_keys($this->properties ?? [])),
|
||||||
|
'openapi' => [
|
||||||
|
'allowEmptyValue' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
|
||||||
|
{
|
||||||
|
if ('search' !== $property || !$value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = $this->properties ?? [];
|
||||||
|
if (empty($fields)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$alias = $queryBuilder->getRootAliases()[0];
|
||||||
|
$orConditions = [];
|
||||||
|
|
||||||
|
foreach (array_keys($fields) as $field) {
|
||||||
|
$paramName = $queryNameGenerator->generateParameterName($field);
|
||||||
|
$orConditions[] = sprintf('LOWER(%s.%s) LIKE LOWER(:%s)', $alias, $field, $paramName);
|
||||||
|
$queryBuilder->setParameter($paramName, '%'.$value.'%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$queryBuilder->andWhere(implode(' OR ', $orConditions));
|
||||||
|
}
|
||||||
|
}
|
||||||
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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/Mcp/Tool/ActivityLogTool.php
Normal file
62
src/Mcp/Tool/ActivityLogTool.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Repository\AuditLogRepository;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
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 = ''): CallToolResult
|
||||||
|
{
|
||||||
|
$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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/Mcp/Tool/Comment/CreateCommentTool.php
Normal file
86
src/Mcp/Tool/Comment/CreateCommentTool.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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 = '',
|
||||||
|
): CallToolResult {
|
||||||
|
$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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/Mcp/Tool/Comment/ListCommentsTool.php
Normal file
68
src/Mcp/Tool/Comment/ListCommentsTool.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?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;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[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): CallToolResult
|
||||||
|
{
|
||||||
|
$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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/Mcp/Tool/Comment/ResolveCommentTool.php
Normal file
66
src/Mcp/Tool/Comment/ResolveCommentTool.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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): CallToolResult
|
||||||
|
{
|
||||||
|
$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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/Mcp/Tool/Comment/UnresolvedCountTool.php
Normal file
38
src/Mcp/Tool/Comment/UnresolvedCountTool.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?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;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[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(): CallToolResult
|
||||||
|
{
|
||||||
|
$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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/Mcp/Tool/Composant/CreateComposantTool.php
Normal file
81
src/Mcp/Tool/Composant/CreateComposantTool.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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 = [],
|
||||||
|
): CallToolResult {
|
||||||
|
$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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Mcp/Tool/Composant/DeleteComposantTool.php
Normal file
43
src/Mcp/Tool/Composant/DeleteComposantTool.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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): CallToolResult
|
||||||
|
{
|
||||||
|
$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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/Mcp/Tool/Composant/GetComposantTool.php
Normal file
60
src/Mcp/Tool/Composant/GetComposantTool.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Composant;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\ComposantRepository;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[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): CallToolResult
|
||||||
|
{
|
||||||
|
$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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Mcp/Tool/Composant/ListComposantsTool.php
Normal file
56
src/Mcp/Tool/Composant/ListComposantsTool.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Composant;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\ComposantRepository;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[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 = ''): CallToolResult
|
||||||
|
{
|
||||||
|
$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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/Mcp/Tool/Composant/UpdateComposantTool.php
Normal file
94
src/Mcp/Tool/Composant/UpdateComposantTool.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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,
|
||||||
|
): CallToolResult {
|
||||||
|
$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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/Mcp/Tool/Constructeur/CreateConstructeurTool.php
Normal file
47
src/Mcp/Tool/Constructeur/CreateConstructeurTool.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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 = '',
|
||||||
|
): CallToolResult {
|
||||||
|
$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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Mcp/Tool/Constructeur/DeleteConstructeurTool.php
Normal file
43
src/Mcp/Tool/Constructeur/DeleteConstructeurTool.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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): CallToolResult
|
||||||
|
{
|
||||||
|
$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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/Mcp/Tool/Constructeur/GetConstructeurTool.php
Normal file
41
src/Mcp/Tool/Constructeur/GetConstructeurTool.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Constructeur;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\ConstructeurRepository;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[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): CallToolResult
|
||||||
|
{
|
||||||
|
$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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Mcp/Tool/Constructeur/ListConstructeursTool.php
Normal file
56
src/Mcp/Tool/Constructeur/ListConstructeursTool.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Constructeur;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\ConstructeurRepository;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[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 = ''): CallToolResult
|
||||||
|
{
|
||||||
|
$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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Mcp/Tool/Constructeur/UpdateConstructeurTool.php
Normal file
56
src/Mcp/Tool/Constructeur/UpdateConstructeurTool.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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,
|
||||||
|
): CallToolResult {
|
||||||
|
$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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/Mcp/Tool/CustomField/DeleteCustomFieldValueTool.php
Normal file
42
src/Mcp/Tool/CustomField/DeleteCustomFieldValueTool.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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): CallToolResult
|
||||||
|
{
|
||||||
|
$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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/Mcp/Tool/CustomField/ListCustomFieldValuesTool.php
Normal file
62
src/Mcp/Tool/CustomField/ListCustomFieldValuesTool.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[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): CallToolResult
|
||||||
|
{
|
||||||
|
$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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/Mcp/Tool/CustomField/UpsertCustomFieldValuesTool.php
Normal file
115
src/Mcp/Tool/CustomField/UpsertCustomFieldValuesTool.php
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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): CallToolResult
|
||||||
|
{
|
||||||
|
$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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/Mcp/Tool/DashboardStatsTool.php
Normal file
51
src/Mcp/Tool/DashboardStatsTool.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?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;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[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(): CallToolResult
|
||||||
|
{
|
||||||
|
$unresolvedComments = (int) $this->em->createQuery(
|
||||||
|
"SELECT COUNT(c.id) FROM App\\Entity\\Comment c WHERE c.status = 'open'"
|
||||||
|
)->getSingleScalarResult();
|
||||||
|
|
||||||
|
return new CallToolResult(
|
||||||
|
content: [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)
|
||||||
|
)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Mcp/Tool/Document/DeleteDocumentTool.php
Normal file
43
src/Mcp/Tool/Document/DeleteDocumentTool.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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): CallToolResult
|
||||||
|
{
|
||||||
|
$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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/Mcp/Tool/Document/ListDocumentsTool.php
Normal file
63
src/Mcp/Tool/Document/ListDocumentsTool.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Document;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\DocumentRepository;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[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): CallToolResult
|
||||||
|
{
|
||||||
|
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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/Mcp/Tool/EntityHistoryTool.php
Normal file
58
src/Mcp/Tool/EntityHistoryTool.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool;
|
||||||
|
|
||||||
|
use App\Repository\AuditLogRepository;
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
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): CallToolResult
|
||||||
|
{
|
||||||
|
$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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/Mcp/Tool/Machine/AddMachineLinksTool.php
Normal file
163
src/Mcp/Tool/Machine/AddMachineLinksTool.php
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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): CallToolResult
|
||||||
|
{
|
||||||
|
$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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
224
src/Mcp/Tool/Machine/CloneMachineTool.php
Normal file
224
src/Mcp/Tool/Machine/CloneMachineTool.php
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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 = '',
|
||||||
|
): CallToolResult {
|
||||||
|
$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], ['createdAt' => 'ASC']);
|
||||||
|
$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], ['createdAt' => 'ASC']);
|
||||||
|
$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], ['createdAt' => 'ASC']);
|
||||||
|
$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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/Mcp/Tool/Machine/CreateMachineTool.php
Normal file
75
src/Mcp/Tool/Machine/CreateMachineTool.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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 = [],
|
||||||
|
): CallToolResult {
|
||||||
|
$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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Mcp/Tool/Machine/DeleteMachineTool.php
Normal file
43
src/Mcp/Tool/Machine/DeleteMachineTool.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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): CallToolResult
|
||||||
|
{
|
||||||
|
$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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/Mcp/Tool/Machine/GetMachineTool.php
Normal file
59
src/Mcp/Tool/Machine/GetMachineTool.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Machine;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\MachineRepository;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[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): CallToolResult
|
||||||
|
{
|
||||||
|
$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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/Mcp/Tool/Machine/ListMachineLinksTool.php
Normal file
71
src/Mcp/Tool/Machine/ListMachineLinksTool.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?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;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[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): CallToolResult
|
||||||
|
{
|
||||||
|
$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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Mcp/Tool/Machine/ListMachinesTool.php
Normal file
56
src/Mcp/Tool/Machine/ListMachinesTool.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Machine;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\MachineRepository;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[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 = ''): CallToolResult
|
||||||
|
{
|
||||||
|
$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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
470
src/Mcp/Tool/Machine/MachineStructureTool.php
Normal file
470
src/Mcp/Tool/Machine/MachineStructureTool.php
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
<?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;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[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): CallToolResult
|
||||||
|
{
|
||||||
|
$machine = $this->machineRepository->find($machineId);
|
||||||
|
|
||||||
|
if (!$machine instanceof Machine) {
|
||||||
|
$this->mcpError('not_found', "Machine not found: {$machineId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
|
||||||
|
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
|
||||||
|
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/Mcp/Tool/Machine/RemoveMachineLinkTool.php
Normal file
52
src/Mcp/Tool/Machine/RemoveMachineLinkTool.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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): CallToolResult
|
||||||
|
{
|
||||||
|
$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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/Mcp/Tool/Machine/UpdateMachineLinkTool.php
Normal file
102
src/Mcp/Tool/Machine/UpdateMachineLinkTool.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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,
|
||||||
|
): CallToolResult {
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/Mcp/Tool/Machine/UpdateMachineTool.php
Normal file
86
src/Mcp/Tool/Machine/UpdateMachineTool.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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,
|
||||||
|
): CallToolResult {
|
||||||
|
$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()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/Mcp/Tool/McpToolHelper.php
Normal file
55
src/Mcp/Tool/McpToolHelper.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool;
|
||||||
|
|
||||||
|
use Mcp\Schema\Content\TextContent;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function jsonResponse(array $data): CallToolResult
|
||||||
|
{
|
||||||
|
return new CallToolResult(
|
||||||
|
content: [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];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function paginatedResponse(array $items, int $total, int $page, int $limit): CallToolResult
|
||||||
|
{
|
||||||
|
return $this->jsonResponse([
|
||||||
|
'items' => $items,
|
||||||
|
'total' => $total,
|
||||||
|
'page' => $page,
|
||||||
|
'limit' => $limit,
|
||||||
|
'pageCount' => (int) ceil($total / max(1, $limit)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/Mcp/Tool/ModelType/CreateModelTypeTool.php
Normal file
58
src/Mcp/Tool/ModelType/CreateModelTypeTool.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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 = ''): CallToolResult
|
||||||
|
{
|
||||||
|
$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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Mcp/Tool/ModelType/DeleteModelTypeTool.php
Normal file
43
src/Mcp/Tool/ModelType/DeleteModelTypeTool.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
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): CallToolResult
|
||||||
|
{
|
||||||
|
$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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/Mcp/Tool/ModelType/GetModelTypeTool.php
Normal file
79
src/Mcp/Tool/ModelType/GetModelTypeTool.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\ModelType;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\ModelTypeRepository;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[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): CallToolResult
|
||||||
|
{
|
||||||
|
$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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/Mcp/Tool/ModelType/ListModelTypesTool.php
Normal file
80
src/Mcp/Tool/ModelType/ListModelTypesTool.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\ModelType;
|
||||||
|
|
||||||
|
use App\Enum\ModelCategory;
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\ModelTypeRepository;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'list_model_types',
|
||||||
|
description: 'List model types with pagination. Filterable by category (composant, piece, product).',
|
||||||
|
)]
|
||||||
|
class ListModelTypesTool
|
||||||
|
{
|
||||||
|
use McpToolHelper;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ModelTypeRepository $modelTypes,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $page = 1, int $limit = 30, string $category = ''): CallToolResult
|
||||||
|
{
|
||||||
|
$p = $this->paginationParams($page, $limit);
|
||||||
|
|
||||||
|
$countQb = $this->modelTypes->createQueryBuilder('mt')
|
||||||
|
->select('COUNT(mt.id)')
|
||||||
|
;
|
||||||
|
|
||||||
|
$qb = $this->modelTypes->createQueryBuilder('mt')
|
||||||
|
->select('mt.id', 'mt.name', 'mt.code', 'mt.category')
|
||||||
|
->orderBy('mt.name', 'ASC')
|
||||||
|
;
|
||||||
|
|
||||||
|
if ('' !== $category) {
|
||||||
|
$enumCategory = $this->resolveCategory($category);
|
||||||
|
|
||||||
|
if (null === $enumCategory) {
|
||||||
|
$this->mcpError('validation', "Invalid category '{$category}'. Must be one of: composant, piece, product.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$countQb->andWhere('mt.category = :category')
|
||||||
|
->setParameter('category', $enumCategory)
|
||||||
|
;
|
||||||
|
$qb->andWhere('mt.category = :category')
|
||||||
|
->setParameter('category', $enumCategory)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (int) $countQb->getQuery()->getSingleScalarResult();
|
||||||
|
|
||||||
|
$items = $qb->setFirstResult($p['offset'])
|
||||||
|
->setMaxResults($p['limit'])
|
||||||
|
->getQuery()
|
||||||
|
->getArrayResult()
|
||||||
|
;
|
||||||
|
|
||||||
|
// Convert ModelCategory enum to string in results
|
||||||
|
foreach ($items as &$item) {
|
||||||
|
if ($item['category'] instanceof ModelCategory) {
|
||||||
|
$item['category'] = $item['category']->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveCategory(string $category): ?ModelCategory
|
||||||
|
{
|
||||||
|
return match (strtolower($category)) {
|
||||||
|
'composant', 'component' => ModelCategory::COMPONENT,
|
||||||
|
'piece' => ModelCategory::PIECE,
|
||||||
|
'product' => ModelCategory::PRODUCT,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/Mcp/Tool/ModelType/SyncModelTypeTool.php
Normal file
68
src/Mcp/Tool/ModelType/SyncModelTypeTool.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\ModelType;
|
||||||
|
|
||||||
|
use App\DTO\SyncConfirmation;
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\ModelTypeRepository;
|
||||||
|
use App\Service\ModelTypeSyncService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'sync_model_type',
|
||||||
|
description: 'Preview or sync a model type structure. Action "preview" shows what would change. Action "sync" applies the pending structure. Requires ROLE_GESTIONNAIRE.',
|
||||||
|
)]
|
||||||
|
class SyncModelTypeTool
|
||||||
|
{
|
||||||
|
use McpToolHelper;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ModelTypeRepository $modelTypes,
|
||||||
|
private readonly ModelTypeSyncService $syncService,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(
|
||||||
|
string $modelTypeId,
|
||||||
|
string $action,
|
||||||
|
?array $structure = null,
|
||||||
|
bool $confirmDeletions = false,
|
||||||
|
bool $confirmTypeChanges = false,
|
||||||
|
): CallToolResult {
|
||||||
|
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||||
|
|
||||||
|
if (!in_array($action, ['preview', 'sync'], true)) {
|
||||||
|
$this->mcpError('validation', "Invalid action '{$action}'. Must be 'preview' or 'sync'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$mt = $this->modelTypes->find($modelTypeId);
|
||||||
|
|
||||||
|
if (!$mt) {
|
||||||
|
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('preview' === $action) {
|
||||||
|
$result = $this->syncService->preview($mt, $structure ?? []);
|
||||||
|
|
||||||
|
return $this->jsonResponse($result->jsonSerialize());
|
||||||
|
}
|
||||||
|
|
||||||
|
// sync action
|
||||||
|
$confirmation = new SyncConfirmation(
|
||||||
|
confirmDeletions: $confirmDeletions,
|
||||||
|
confirmTypeChanges: $confirmTypeChanges,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->em->wrapInTransaction(function () use ($mt, $confirmation) {
|
||||||
|
return $this->syncService->execute($mt, $confirmation);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $this->jsonResponse($result->jsonSerialize());
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/Mcp/Tool/ModelType/UpdateModelTypeTool.php
Normal file
54
src/Mcp/Tool/ModelType/UpdateModelTypeTool.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?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 Mcp\Schema\Result\CallToolResult;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'update_model_type',
|
||||||
|
description: 'Update an existing model type. Only provided fields are changed. Requires ROLE_GESTIONNAIRE.',
|
||||||
|
)]
|
||||||
|
class UpdateModelTypeTool
|
||||||
|
{
|
||||||
|
use McpToolHelper;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ModelTypeRepository $modelTypes,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(string $modelTypeId, ?string $name = null, ?string $code = null): CallToolResult
|
||||||
|
{
|
||||||
|
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||||
|
|
||||||
|
$mt = $this->modelTypes->find($modelTypeId);
|
||||||
|
|
||||||
|
if (!$mt) {
|
||||||
|
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $name) {
|
||||||
|
$mt->setName($name);
|
||||||
|
}
|
||||||
|
if (null !== $code) {
|
||||||
|
$mt->setCode($code);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $this->jsonResponse([
|
||||||
|
'id' => $mt->getId(),
|
||||||
|
'name' => $mt->getName(),
|
||||||
|
'code' => $mt->getCode(),
|
||||||
|
'category' => $mt->getCategory()->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/Mcp/Tool/Piece/CreatePieceTool.php
Normal file
81
src/Mcp/Tool/Piece/CreatePieceTool.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Piece;
|
||||||
|
|
||||||
|
use App\Entity\Piece;
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\ConstructeurRepository;
|
||||||
|
use App\Repository\ModelTypeRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'create_piece',
|
||||||
|
description: 'Create a new piece. prix must be a string (e.g. "12.50"). Requires ROLE_GESTIONNAIRE.',
|
||||||
|
)]
|
||||||
|
class CreatePieceTool
|
||||||
|
{
|
||||||
|
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 = [],
|
||||||
|
): CallToolResult {
|
||||||
|
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||||
|
|
||||||
|
$piece = new Piece();
|
||||||
|
$piece->setName($name);
|
||||||
|
|
||||||
|
if ('' !== $reference) {
|
||||||
|
$piece->setReference($reference);
|
||||||
|
}
|
||||||
|
if ('' !== $description) {
|
||||||
|
$piece->setDescription($description);
|
||||||
|
}
|
||||||
|
if ('' !== $prix) {
|
||||||
|
$piece->setPrix($prix);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('' !== $modelTypeId) {
|
||||||
|
$modelType = $this->modelTypes->find($modelTypeId);
|
||||||
|
if (!$modelType) {
|
||||||
|
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
|
||||||
|
}
|
||||||
|
$piece->setTypePiece($modelType);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($constructeurIds as $cId) {
|
||||||
|
$c = $this->constructeurs->find($cId);
|
||||||
|
if (!$c) {
|
||||||
|
$this->mcpError('not_found', "Constructeur not found: {$cId}");
|
||||||
|
}
|
||||||
|
$piece->addConstructeur($c);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($piece);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $this->jsonResponse([
|
||||||
|
'id' => $piece->getId(),
|
||||||
|
'name' => $piece->getName(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Mcp/Tool/Piece/DeletePieceTool.php
Normal file
43
src/Mcp/Tool/Piece/DeletePieceTool.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Piece;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\PieceRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'delete_piece',
|
||||||
|
description: 'Delete a piece by ID. Requires ROLE_GESTIONNAIRE.',
|
||||||
|
)]
|
||||||
|
class DeletePieceTool
|
||||||
|
{
|
||||||
|
use McpToolHelper;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly PieceRepository $pieces,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(string $pieceId): CallToolResult
|
||||||
|
{
|
||||||
|
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||||
|
|
||||||
|
$piece = $this->pieces->find($pieceId);
|
||||||
|
|
||||||
|
if (!$piece) {
|
||||||
|
$this->mcpError('not_found', "Piece not found: {$pieceId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->remove($piece);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $this->jsonResponse(['deleted' => true, 'id' => $pieceId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/Mcp/Tool/Piece/GetPieceTool.php
Normal file
60
src/Mcp/Tool/Piece/GetPieceTool.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Piece;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\PieceRepository;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'get_piece',
|
||||||
|
description: 'Get a single piece by ID with all its details, including typePiece and constructeurs.',
|
||||||
|
)]
|
||||||
|
class GetPieceTool
|
||||||
|
{
|
||||||
|
use McpToolHelper;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly PieceRepository $pieces,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(string $pieceId): CallToolResult
|
||||||
|
{
|
||||||
|
$piece = $this->pieces->find($pieceId);
|
||||||
|
|
||||||
|
if (!$piece) {
|
||||||
|
$this->mcpError('not_found', "Piece not found: {$pieceId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$constructeurs = [];
|
||||||
|
foreach ($piece->getConstructeurs() as $c) {
|
||||||
|
$constructeurs[] = [
|
||||||
|
'id' => $c->getId(),
|
||||||
|
'name' => $c->getName(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$typePiece = null;
|
||||||
|
if ($piece->getTypePiece()) {
|
||||||
|
$typePiece = [
|
||||||
|
'id' => $piece->getTypePiece()->getId(),
|
||||||
|
'name' => $piece->getTypePiece()->getName(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->jsonResponse([
|
||||||
|
'id' => $piece->getId(),
|
||||||
|
'name' => $piece->getName(),
|
||||||
|
'reference' => $piece->getReference(),
|
||||||
|
'description' => $piece->getDescription(),
|
||||||
|
'prix' => $piece->getPrix(),
|
||||||
|
'typePiece' => $typePiece,
|
||||||
|
'constructeurs' => $constructeurs,
|
||||||
|
'createdAt' => $piece->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||||
|
'updatedAt' => $piece->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Mcp/Tool/Piece/ListPiecesTool.php
Normal file
56
src/Mcp/Tool/Piece/ListPiecesTool.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Piece;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\PieceRepository;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'list_pieces',
|
||||||
|
description: 'List pieces with pagination. Filterable by name or reference.',
|
||||||
|
)]
|
||||||
|
class ListPiecesTool
|
||||||
|
{
|
||||||
|
use McpToolHelper;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly PieceRepository $pieces,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): CallToolResult
|
||||||
|
{
|
||||||
|
$p = $this->paginationParams($page, $limit);
|
||||||
|
|
||||||
|
$countQb = $this->pieces->createQueryBuilder('pi')
|
||||||
|
->select('COUNT(pi.id)')
|
||||||
|
;
|
||||||
|
|
||||||
|
$qb = $this->pieces->createQueryBuilder('pi')
|
||||||
|
->select('pi.id', 'pi.name', 'pi.reference', 'pi.prix')
|
||||||
|
->orderBy('pi.name', 'ASC')
|
||||||
|
;
|
||||||
|
|
||||||
|
if ('' !== $search) {
|
||||||
|
$countQb->andWhere('LOWER(pi.name) LIKE LOWER(:search) OR LOWER(pi.reference) LIKE LOWER(:search)')
|
||||||
|
->setParameter('search', "%{$search}%")
|
||||||
|
;
|
||||||
|
$qb->andWhere('LOWER(pi.name) LIKE LOWER(:search) OR LOWER(pi.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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/Mcp/Tool/Piece/UpdatePieceTool.php
Normal file
94
src/Mcp/Tool/Piece/UpdatePieceTool.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Piece;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\ConstructeurRepository;
|
||||||
|
use App\Repository\ModelTypeRepository;
|
||||||
|
use App\Repository\PieceRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'update_piece',
|
||||||
|
description: 'Update an existing piece. Only provided fields are changed. prix must be a string. Requires ROLE_GESTIONNAIRE.',
|
||||||
|
)]
|
||||||
|
class UpdatePieceTool
|
||||||
|
{
|
||||||
|
use McpToolHelper;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly PieceRepository $pieces,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly Security $security,
|
||||||
|
private readonly ModelTypeRepository $modelTypes,
|
||||||
|
private readonly ConstructeurRepository $constructeurs,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param null|string[] $constructeurIds
|
||||||
|
*/
|
||||||
|
public function __invoke(
|
||||||
|
string $pieceId,
|
||||||
|
?string $name = null,
|
||||||
|
?string $reference = null,
|
||||||
|
?string $description = null,
|
||||||
|
?string $prix = null,
|
||||||
|
?string $modelTypeId = null,
|
||||||
|
?array $constructeurIds = null,
|
||||||
|
): CallToolResult {
|
||||||
|
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||||
|
|
||||||
|
$piece = $this->pieces->find($pieceId);
|
||||||
|
|
||||||
|
if (!$piece) {
|
||||||
|
$this->mcpError('not_found', "Piece not found: {$pieceId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $name) {
|
||||||
|
$piece->setName($name);
|
||||||
|
}
|
||||||
|
if (null !== $reference) {
|
||||||
|
$piece->setReference($reference);
|
||||||
|
}
|
||||||
|
if (null !== $description) {
|
||||||
|
$piece->setDescription($description);
|
||||||
|
}
|
||||||
|
if (null !== $prix) {
|
||||||
|
$piece->setPrix($prix);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $modelTypeId) {
|
||||||
|
if ('' === $modelTypeId) {
|
||||||
|
$piece->setTypePiece(null);
|
||||||
|
} else {
|
||||||
|
$modelType = $this->modelTypes->find($modelTypeId);
|
||||||
|
if (!$modelType) {
|
||||||
|
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
|
||||||
|
}
|
||||||
|
$piece->setTypePiece($modelType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $constructeurIds) {
|
||||||
|
foreach ($piece->getConstructeurs()->toArray() as $existing) {
|
||||||
|
$piece->removeConstructeur($existing);
|
||||||
|
}
|
||||||
|
foreach ($constructeurIds as $cId) {
|
||||||
|
$c = $this->constructeurs->find($cId);
|
||||||
|
if (!$c) {
|
||||||
|
$this->mcpError('not_found', "Constructeur not found: {$cId}");
|
||||||
|
}
|
||||||
|
$piece->addConstructeur($c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $this->jsonResponse(['id' => $piece->getId(), 'name' => $piece->getName()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/Mcp/Tool/Product/CreateProductTool.php
Normal file
77
src/Mcp/Tool/Product/CreateProductTool.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Product;
|
||||||
|
|
||||||
|
use App\Entity\Product;
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\ConstructeurRepository;
|
||||||
|
use App\Repository\ModelTypeRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'create_product',
|
||||||
|
description: 'Create a new product. supplierPrice must be a string (e.g. "12.50"). Requires ROLE_GESTIONNAIRE.',
|
||||||
|
)]
|
||||||
|
class CreateProductTool
|
||||||
|
{
|
||||||
|
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 $supplierPrice = '',
|
||||||
|
string $modelTypeId = '',
|
||||||
|
array $constructeurIds = [],
|
||||||
|
): CallToolResult {
|
||||||
|
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||||
|
|
||||||
|
$product = new Product();
|
||||||
|
$product->setName($name);
|
||||||
|
|
||||||
|
if ('' !== $reference) {
|
||||||
|
$product->setReference($reference);
|
||||||
|
}
|
||||||
|
if ('' !== $supplierPrice) {
|
||||||
|
$product->setSupplierPrice($supplierPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('' !== $modelTypeId) {
|
||||||
|
$modelType = $this->modelTypes->find($modelTypeId);
|
||||||
|
if (!$modelType) {
|
||||||
|
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
|
||||||
|
}
|
||||||
|
$product->setTypeProduct($modelType);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($constructeurIds as $cId) {
|
||||||
|
$c = $this->constructeurs->find($cId);
|
||||||
|
if (!$c) {
|
||||||
|
$this->mcpError('not_found', "Constructeur not found: {$cId}");
|
||||||
|
}
|
||||||
|
$product->addConstructeur($c);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->persist($product);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $this->jsonResponse([
|
||||||
|
'id' => $product->getId(),
|
||||||
|
'name' => $product->getName(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Mcp/Tool/Product/DeleteProductTool.php
Normal file
43
src/Mcp/Tool/Product/DeleteProductTool.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Product;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\ProductRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'delete_product',
|
||||||
|
description: 'Delete a product by ID. Requires ROLE_GESTIONNAIRE.',
|
||||||
|
)]
|
||||||
|
class DeleteProductTool
|
||||||
|
{
|
||||||
|
use McpToolHelper;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProductRepository $products,
|
||||||
|
private readonly EntityManagerInterface $em,
|
||||||
|
private readonly Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(string $productId): CallToolResult
|
||||||
|
{
|
||||||
|
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||||
|
|
||||||
|
$product = $this->products->find($productId);
|
||||||
|
|
||||||
|
if (!$product) {
|
||||||
|
$this->mcpError('not_found', "Product not found: {$productId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->remove($product);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $this->jsonResponse(['deleted' => true, 'id' => $productId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/Mcp/Tool/Product/GetProductTool.php
Normal file
59
src/Mcp/Tool/Product/GetProductTool.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Product;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\ProductRepository;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'get_product',
|
||||||
|
description: 'Get a single product by ID with all its details, including typeProduct and constructeurs.',
|
||||||
|
)]
|
||||||
|
class GetProductTool
|
||||||
|
{
|
||||||
|
use McpToolHelper;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProductRepository $products,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(string $productId): CallToolResult
|
||||||
|
{
|
||||||
|
$product = $this->products->find($productId);
|
||||||
|
|
||||||
|
if (!$product) {
|
||||||
|
$this->mcpError('not_found', "Product not found: {$productId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$constructeurs = [];
|
||||||
|
foreach ($product->getConstructeurs() as $c) {
|
||||||
|
$constructeurs[] = [
|
||||||
|
'id' => $c->getId(),
|
||||||
|
'name' => $c->getName(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$typeProduct = null;
|
||||||
|
if ($product->getTypeProduct()) {
|
||||||
|
$typeProduct = [
|
||||||
|
'id' => $product->getTypeProduct()->getId(),
|
||||||
|
'name' => $product->getTypeProduct()->getName(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->jsonResponse([
|
||||||
|
'id' => $product->getId(),
|
||||||
|
'name' => $product->getName(),
|
||||||
|
'reference' => $product->getReference(),
|
||||||
|
'supplierPrice' => $product->getSupplierPrice(),
|
||||||
|
'typeProduct' => $typeProduct,
|
||||||
|
'constructeurs' => $constructeurs,
|
||||||
|
'createdAt' => $product->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||||
|
'updatedAt' => $product->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/Mcp/Tool/Product/ListProductsTool.php
Normal file
56
src/Mcp/Tool/Product/ListProductsTool.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Tool\Product;
|
||||||
|
|
||||||
|
use App\Mcp\Tool\McpToolHelper;
|
||||||
|
use App\Repository\ProductRepository;
|
||||||
|
use Mcp\Capability\Attribute\McpTool;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
|
||||||
|
#[McpTool(
|
||||||
|
name: 'list_products',
|
||||||
|
description: 'List products with pagination. Filterable by name or reference.',
|
||||||
|
)]
|
||||||
|
class ListProductsTool
|
||||||
|
{
|
||||||
|
use McpToolHelper;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ProductRepository $products,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): CallToolResult
|
||||||
|
{
|
||||||
|
$p = $this->paginationParams($page, $limit);
|
||||||
|
|
||||||
|
$countQb = $this->products->createQueryBuilder('pr')
|
||||||
|
->select('COUNT(pr.id)')
|
||||||
|
;
|
||||||
|
|
||||||
|
$qb = $this->products->createQueryBuilder('pr')
|
||||||
|
->select('pr.id', 'pr.name', 'pr.reference', 'pr.supplierPrice')
|
||||||
|
->orderBy('pr.name', 'ASC')
|
||||||
|
;
|
||||||
|
|
||||||
|
if ('' !== $search) {
|
||||||
|
$countQb->andWhere('LOWER(pr.name) LIKE LOWER(:search) OR LOWER(pr.reference) LIKE LOWER(:search)')
|
||||||
|
->setParameter('search', "%{$search}%")
|
||||||
|
;
|
||||||
|
$qb->andWhere('LOWER(pr.name) LIKE LOWER(:search) OR LOWER(pr.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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user