Compare commits
13 Commits
v1.9.1
...
f965affc94
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f965affc94 | ||
|
|
4340a0e13e | ||
|
|
bd7259ed05 | ||
|
|
2f173e766d | ||
|
|
4f1e136dc5 | ||
|
|
e335f4c24c | ||
|
|
46ea3ca8ad | ||
|
|
65fbd38b55 | ||
|
|
37aa755819 | ||
|
|
98caaa148d | ||
|
|
523eed927e | ||
|
|
43bec07bb8 | ||
|
|
0181f18778 |
14
.mcp.json
Normal file
14
.mcp.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"inventory": {
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"exec", "-i",
|
||||
"-e", "MCP_PROFILE_ID=REPLACE_WITH_YOUR_PROFILE_ID",
|
||||
"-e", "MCP_PROFILE_PASSWORD=REPLACE_WITH_YOUR_PASSWORD",
|
||||
"php-inventory-apache",
|
||||
"php", "bin/console", "mcp:server"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Submodule Inventory_frontend updated: 428da471d1...d4fc0f1fee
@@ -14,6 +14,7 @@
|
||||
"doctrine/orm": "^3.6",
|
||||
"lexik/jwt-authentication-bundle": "^3.2",
|
||||
"nelmio/cors-bundle": "^2.6",
|
||||
"nyholm/psr7": "^1.8",
|
||||
"phpdocumentor/reflection-docblock": "^5.6",
|
||||
"phpstan/phpdoc-parser": "^2.3",
|
||||
"symfony/asset": "8.0.*",
|
||||
@@ -22,8 +23,10 @@
|
||||
"symfony/expression-language": "8.0.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "8.0.*",
|
||||
"symfony/mcp-bundle": "^0.6.0",
|
||||
"symfony/property-access": "8.0.*",
|
||||
"symfony/property-info": "8.0.*",
|
||||
"symfony/rate-limiter": "8.0.*",
|
||||
"symfony/runtime": "8.0.*",
|
||||
"symfony/security-bundle": "8.0.*",
|
||||
"symfony/serializer": "8.0.*",
|
||||
|
||||
1033
composer.lock
generated
1033
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
|
||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
|
||||
use Nelmio\CorsBundle\NelmioCorsBundle;
|
||||
use Symfony\AI\McpBundle\McpBundle;
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
use Symfony\Bundle\SecurityBundle\SecurityBundle;
|
||||
use Symfony\Bundle\TwigBundle\TwigBundle;
|
||||
@@ -22,4 +23,5 @@ return [
|
||||
ApiPlatformBundle::class => ['all' => true],
|
||||
LexikJWTAuthenticationBundle::class => ['all' => true],
|
||||
DAMADoctrineTestBundle::class => ['test' => true],
|
||||
McpBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
10
config/packages/http_discovery.yaml
Normal file
10
config/packages/http_discovery.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
|
||||
|
||||
http_discovery.psr17_factory:
|
||||
class: Http\Discovery\Psr17Factory
|
||||
20
config/packages/mcp.yaml
Normal file
20
config/packages/mcp.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
mcp:
|
||||
app: 'inventory'
|
||||
version: '1.0.0'
|
||||
description: 'Inventory MCP Server - Gestion inventaire industriel (machines, pièces, composants, produits)'
|
||||
instructions: |
|
||||
Serveur MCP pour gérer un inventaire industriel.
|
||||
Entités principales : Machine, Composant, Pièce, Produit, Site, Constructeur.
|
||||
Utilisez search_inventory pour chercher dans toutes les entités.
|
||||
Utilisez get_model_type pour comprendre la structure attendue avant de créer un composant ou une pièce.
|
||||
Consultez la resource inventory://schema/entities pour voir le schéma complet.
|
||||
Authentification requise : envoyez X-Profile-Id et X-Profile-Password dans les headers HTTP.
|
||||
client_transports:
|
||||
stdio: true
|
||||
http: true
|
||||
http:
|
||||
path: /_mcp
|
||||
session:
|
||||
store: file
|
||||
directory: '%kernel.cache_dir%/mcp-sessions'
|
||||
ttl: 3600
|
||||
20
config/packages/mcp.yaml.disabled
Normal file
20
config/packages/mcp.yaml.disabled
Normal file
@@ -0,0 +1,20 @@
|
||||
mcp:
|
||||
app: 'inventory'
|
||||
version: '1.0.0'
|
||||
description: 'Inventory MCP Server - Gestion inventaire industriel (machines, pièces, composants, produits)'
|
||||
instructions: |
|
||||
Serveur MCP pour gérer un inventaire industriel.
|
||||
Entités principales : Machine, Composant, Pièce, Produit, Site, Constructeur.
|
||||
Utilisez search_inventory pour chercher dans toutes les entités.
|
||||
Utilisez get_model_type pour comprendre la structure attendue avant de créer un composant ou une pièce.
|
||||
Consultez la resource inventory://schema/entities pour voir le schéma complet.
|
||||
Authentification requise : envoyez X-Profile-Id et X-Profile-Password dans les headers HTTP.
|
||||
client_transports:
|
||||
stdio: true
|
||||
http: true
|
||||
http:
|
||||
path: /_mcp
|
||||
session:
|
||||
store: file
|
||||
directory: '%kernel.cache_dir%/mcp-sessions'
|
||||
ttl: 3600
|
||||
6
config/packages/rate_limiter.yaml
Normal file
6
config/packages/rate_limiter.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
framework:
|
||||
rate_limiter:
|
||||
mcp_auth:
|
||||
policy: sliding_window
|
||||
limit: 5
|
||||
interval: '1 minute'
|
||||
6
config/packages/rate_limiter.yaml.disabled
Normal file
6
config/packages/rate_limiter.yaml.disabled
Normal file
@@ -0,0 +1,6 @@
|
||||
framework:
|
||||
rate_limiter:
|
||||
mcp_auth:
|
||||
policy: sliding_window
|
||||
limit: 5
|
||||
interval: '1 minute'
|
||||
@@ -27,6 +27,12 @@ security:
|
||||
pattern: ^/api/session/profiles?$
|
||||
security: false
|
||||
|
||||
mcp:
|
||||
pattern: ^/_mcp
|
||||
stateless: true
|
||||
custom_authenticators:
|
||||
- App\Mcp\Security\McpHeaderAuthenticator
|
||||
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: false
|
||||
@@ -49,6 +55,7 @@ security:
|
||||
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/_mcp, roles: ROLE_USER }
|
||||
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
|
||||
|
||||
@@ -12,3 +12,7 @@ api_login_check:
|
||||
|
||||
controllers:
|
||||
resource: routing.controllers
|
||||
|
||||
mcp:
|
||||
resource: .
|
||||
type: mcp
|
||||
|
||||
@@ -34,6 +34,10 @@ services:
|
||||
tags:
|
||||
- { name: doctrine.event_subscriber }
|
||||
|
||||
App\Mcp\Security\McpHeaderAuthenticator:
|
||||
arguments:
|
||||
$mcpAuthLimiter: '@limiter.mcp_auth'
|
||||
|
||||
App\OpenApi\OpenApiDecorator:
|
||||
decorates: 'api_platform.openapi.factory'
|
||||
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.
|
||||
35
src/Mcp/Resource/RolesResource.php
Normal file
35
src/Mcp/Resource/RolesResource.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Resource;
|
||||
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
|
||||
#[McpResource(
|
||||
uri: 'inventory://roles',
|
||||
name: 'Roles & Permissions',
|
||||
description: 'Role hierarchy and permissions for MCP tools.',
|
||||
mimeType: 'application/json'
|
||||
)]
|
||||
class RolesResource
|
||||
{
|
||||
public function __invoke(): array
|
||||
{
|
||||
$roles = [
|
||||
'hierarchy' => [
|
||||
'ROLE_ADMIN' => 'Inherits ROLE_GESTIONNAIRE. Can manage profiles.',
|
||||
'ROLE_GESTIONNAIRE' => 'Inherits ROLE_VIEWER. Can create, update, delete all entities.',
|
||||
'ROLE_VIEWER' => 'Inherits ROLE_USER. Can read all entities, create comments, search.',
|
||||
'ROLE_USER' => 'Base role. Authenticated but minimal access.',
|
||||
],
|
||||
'tool_permissions' => [
|
||||
'ROLE_VIEWER' => 'list_*, get_*, search_inventory, get_dashboard_stats, get_entity_history, get_activity_log, list_comments, create_comment, get_unresolved_comments_count, list_custom_field_values, list_documents, list_slots',
|
||||
'ROLE_GESTIONNAIRE' => 'All VIEWER tools + create_*, update_*, delete_*, clone_machine, update_slots, add_machine_links, remove_machine_link, resolve_comment, upsert_custom_field_values, sync_model_type',
|
||||
],
|
||||
];
|
||||
|
||||
return [new TextContent(text: json_encode($roles, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))];
|
||||
}
|
||||
}
|
||||
53
src/Mcp/Resource/SchemaResource.php
Normal file
53
src/Mcp/Resource/SchemaResource.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Resource;
|
||||
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
|
||||
#[McpResource(
|
||||
uri: 'inventory://schema/entities',
|
||||
name: 'Entity Schema',
|
||||
description: 'Complete schema of all inventory entities with their fields, types, and relationships.',
|
||||
mimeType: 'application/json'
|
||||
)]
|
||||
class SchemaResource
|
||||
{
|
||||
public function __invoke(): array
|
||||
{
|
||||
$schema = [
|
||||
'Machine' => [
|
||||
'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'prix (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['site (Site, required)', 'constructeurs (Constructeur[])', 'componentLinks (MachineComponentLink[])', 'pieceLinks (MachinePieceLink[])', 'productLinks (MachineProductLink[])', 'customFields (CustomField[])', 'customFieldValues (CustomFieldValue[])'],
|
||||
],
|
||||
'Composant' => [
|
||||
'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'description (text?)', 'prix (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['typeComposant (ModelType?)', 'constructeurs (Constructeur[])', 'pieceSlots (ComposantPieceSlot[])', 'productSlots (ComposantProductSlot[])', 'subcomponentSlots (ComposantSubcomponentSlot[])', 'customFieldValues (CustomFieldValue[])'],
|
||||
],
|
||||
'Piece' => [
|
||||
'fields' => ['id (string)', 'name (string)', 'reference (string?, unique)', 'description (text?)', 'prix (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['typePiece (ModelType?)', 'product (Product?)', 'constructeurs (Constructeur[])', 'productSlots (PieceProductSlot[])', 'customFieldValues (CustomFieldValue[])'],
|
||||
],
|
||||
'Product' => [
|
||||
'fields' => ['id (string)', 'name (string, unique)', 'reference (string?)', 'supplierPrice (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['typeProduct (ModelType?)', 'constructeurs (Constructeur[])'],
|
||||
],
|
||||
'Site' => [
|
||||
'fields' => ['id (string)', 'name (string)', 'contactName (string)', 'contactPhone (string)', 'contactAddress (string)', 'contactPostalCode (string)', 'contactCity (string)', 'color (string)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['machines (Machine[])'],
|
||||
],
|
||||
'Constructeur' => [
|
||||
'fields' => ['id (string)', 'name (string, unique)', 'email (string?)', 'phone (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['machines (Machine[])', 'composants (Composant[])', 'pieces (Piece[])', 'products (Product[])'],
|
||||
],
|
||||
'ModelType' => [
|
||||
'fields' => ['id (string)', 'name (string)', 'category (machine|composant|piece|product)', 'code (string?)', 'createdAt', 'updatedAt'],
|
||||
'relationships' => ['skeletonPieceRequirements[]', 'skeletonProductRequirements[]', 'skeletonSubcomponentRequirements[]'],
|
||||
],
|
||||
];
|
||||
|
||||
return [new TextContent(text: json_encode($schema, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE))];
|
||||
}
|
||||
}
|
||||
48
src/Mcp/Resource/StatsResource.php
Normal file
48
src/Mcp/Resource/StatsResource.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Resource;
|
||||
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\SiteRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpResource;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
|
||||
#[McpResource(
|
||||
uri: 'inventory://stats',
|
||||
name: 'Inventory Statistics',
|
||||
description: 'Global counters: machines, pieces, composants, products, sites, unresolved comments.',
|
||||
mimeType: 'application/json'
|
||||
)]
|
||||
class StatsResource
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly PieceRepository $pieces,
|
||||
private readonly ComposantRepository $composants,
|
||||
private readonly ProductRepository $products,
|
||||
private readonly SiteRepository $sites,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function __invoke(): array
|
||||
{
|
||||
$unresolvedComments = (int) $this->em->createQuery(
|
||||
"SELECT COUNT(c.id) FROM App\\Entity\\Comment c WHERE c.status = 'open'"
|
||||
)->getSingleScalarResult();
|
||||
|
||||
return [new TextContent(text: json_encode([
|
||||
'machines' => $this->machines->count([]),
|
||||
'pieces' => $this->pieces->count([]),
|
||||
'composants' => $this->composants->count([]),
|
||||
'products' => $this->products->count([]),
|
||||
'sites' => $this->sites->count([]),
|
||||
'unresolvedComments' => $unresolvedComments,
|
||||
], JSON_THROW_ON_ERROR))];
|
||||
}
|
||||
}
|
||||
100
src/Mcp/Security/McpHeaderAuthenticator.php
Normal file
100
src/Mcp/Security/McpHeaderAuthenticator.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Security;
|
||||
|
||||
use App\Entity\Profile;
|
||||
use App\Repository\ProfileRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\RateLimiter\RateLimiterFactory;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\AuthenticationException;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
|
||||
|
||||
final class McpHeaderAuthenticator extends AbstractAuthenticator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly RateLimiterFactory $mcpAuthLimiter,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function supports(Request $request): ?bool
|
||||
{
|
||||
if (!$request->headers->has('X-Profile-Id') || !$request->headers->has('X-Profile-Password')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function authenticate(Request $request): Passport
|
||||
{
|
||||
$profileId = $request->headers->get('X-Profile-Id', '');
|
||||
$password = $request->headers->get('X-Profile-Password', '');
|
||||
|
||||
$limiter = $this->mcpAuthLimiter->create($request->getClientIp() ?? 'unknown');
|
||||
$limit = $limiter->consume(1);
|
||||
|
||||
if (!$limit->isAccepted()) {
|
||||
$this->logger->warning('MCP auth rate limited', ['ip' => $request->getClientIp()]);
|
||||
|
||||
throw new CustomUserMessageAuthenticationException('Rate limited: too many authentication attempts.');
|
||||
}
|
||||
|
||||
return new SelfValidatingPassport(
|
||||
new UserBadge($profileId, function (string $id) use ($password, $limiter, $request): Profile {
|
||||
$profile = $this->profiles->find($id);
|
||||
|
||||
if (!$profile || !$profile->isActive()) {
|
||||
$this->logger->warning('MCP auth failed: profile not found', ['profileId' => $id]);
|
||||
|
||||
throw new CustomUserMessageAuthenticationException('Authentication failed: invalid credentials.');
|
||||
}
|
||||
|
||||
if (!$this->passwordHasher->isPasswordValid($profile, $password)) {
|
||||
$this->logger->warning('MCP auth failed: invalid password', ['profileId' => $id]);
|
||||
|
||||
throw new CustomUserMessageAuthenticationException('Authentication failed: invalid credentials.');
|
||||
}
|
||||
|
||||
$limiter->reset();
|
||||
|
||||
$this->logger->info('MCP auth success', [
|
||||
'profileId' => $id,
|
||||
'roles' => $profile->getRoles(),
|
||||
'ip' => $request->getClientIp(),
|
||||
]);
|
||||
|
||||
return $profile;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
|
||||
{
|
||||
$statusCode = str_contains($exception->getMessageKey(), 'Rate limited')
|
||||
? Response::HTTP_TOO_MANY_REQUESTS
|
||||
: Response::HTTP_UNAUTHORIZED;
|
||||
|
||||
return new JsonResponse(
|
||||
['message' => $exception->getMessageKey()],
|
||||
$statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
71
src/Mcp/Security/McpStdioAuthSubscriber.php
Normal file
71
src/Mcp/Security/McpStdioAuthSubscriber.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Security;
|
||||
|
||||
use App\Repository\ProfileRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\ConsoleEvents;
|
||||
use Symfony\Component\Console\Event\ConsoleCommandEvent;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
|
||||
#[AsEventListener(event: ConsoleEvents::COMMAND)]
|
||||
final class McpStdioAuthSubscriber
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProfileRepository $profiles,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly TokenStorageInterface $tokenStorage,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function __invoke(ConsoleCommandEvent $event): void
|
||||
{
|
||||
$command = $event->getCommand();
|
||||
|
||||
if (!$command || !str_starts_with($command->getName() ?? '', 'mcp:')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$profileId = $_ENV['MCP_PROFILE_ID'] ?? '';
|
||||
$password = $_ENV['MCP_PROFILE_PASSWORD'] ?? '';
|
||||
|
||||
if ('' === $profileId || '' === $password) {
|
||||
$this->logger->error('MCP stdio: missing MCP_PROFILE_ID or MCP_PROFILE_PASSWORD env vars');
|
||||
$event->disableCommand();
|
||||
$event->getOutput()->writeln('<error>MCP auth: MCP_PROFILE_ID and MCP_PROFILE_PASSWORD env vars required</error>');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$profile = $this->profiles->find($profileId);
|
||||
|
||||
if (!$profile || !$profile->isActive()) {
|
||||
$this->logger->error('MCP stdio: profile not found or inactive', ['profileId' => $profileId]);
|
||||
$event->disableCommand();
|
||||
$event->getOutput()->writeln('<error>MCP auth: invalid profile</error>');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->passwordHasher->isPasswordValid($profile, $password)) {
|
||||
$this->logger->error('MCP stdio: invalid password', ['profileId' => $profileId]);
|
||||
$event->disableCommand();
|
||||
$event->getOutput()->writeln('<error>MCP auth: invalid password</error>');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$token = new UsernamePasswordToken($profile, 'mcp', $profile->getRoles());
|
||||
$this->tokenStorage->setToken($token);
|
||||
|
||||
$this->logger->info('MCP stdio auth success', [
|
||||
'profileId' => $profileId,
|
||||
'roles' => $profile->getRoles(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
61
src/Mcp/Tool/ActivityLogTool.php
Normal file
61
src/Mcp/Tool/ActivityLogTool.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use DateTimeInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_activity_log',
|
||||
description: 'Get the global activity log with optional filters. Returns paginated audit entries across all entities.',
|
||||
)]
|
||||
class ActivityLogTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $page = 1, int $limit = 30, string $entityType = '', string $action = ''): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_VIEWER');
|
||||
|
||||
$p = $this->paginationParams($page, $limit);
|
||||
|
||||
$filters = [];
|
||||
if ('' !== $entityType) {
|
||||
$filters['entityType'] = $entityType;
|
||||
}
|
||||
if ('' !== $action) {
|
||||
$filters['action'] = $action;
|
||||
}
|
||||
|
||||
$result = $this->auditLogs->findAllPaginated($p['page'], $p['limit'], $filters);
|
||||
|
||||
$items = array_map(
|
||||
static function ($log) {
|
||||
$snapshot = $log->getSnapshot();
|
||||
|
||||
return [
|
||||
'id' => $log->getId(),
|
||||
'entityType' => $log->getEntityType(),
|
||||
'entityId' => $log->getEntityId(),
|
||||
'entityName' => $snapshot['name'] ?? null,
|
||||
'action' => $log->getAction(),
|
||||
'diff' => $log->getDiff(),
|
||||
'actorProfileId' => $log->getActorProfileId(),
|
||||
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
];
|
||||
},
|
||||
$result['items'],
|
||||
);
|
||||
|
||||
return $this->paginatedResponse(array_values($items), $result['total'], $p['page'], $p['limit']);
|
||||
}
|
||||
}
|
||||
85
src/Mcp/Tool/Comment/CreateCommentTool.php
Normal file
85
src/Mcp/Tool/Comment/CreateCommentTool.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Comment;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ProfileRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'create_comment',
|
||||
description: 'Create a comment on an entity (machine, piece, composant, product…). Requires ROLE_VIEWER.',
|
||||
)]
|
||||
class CreateCommentTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $content,
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
string $entityName = '',
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_VIEWER');
|
||||
|
||||
$content = trim($content);
|
||||
if ('' === $content) {
|
||||
$this->mcpError('Validation', 'Le contenu est requis.');
|
||||
}
|
||||
|
||||
$allowedTypes = ['machine', 'piece', 'composant', 'product', 'piece_category', 'component_category', 'product_category', 'machine_skeleton'];
|
||||
if (!in_array($entityType, $allowedTypes, true)) {
|
||||
$this->mcpError('Validation', "Type d'entité invalide : {$entityType}.");
|
||||
}
|
||||
|
||||
$entityId = trim($entityId);
|
||||
if ('' === $entityId) {
|
||||
$this->mcpError('Validation', "L'identifiant de l'entité est requis.");
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
$profile = $user ? $this->profiles->find($user->getUserIdentifier()) : null;
|
||||
|
||||
$authorName = 'Inconnu';
|
||||
$authorId = '';
|
||||
if ($profile) {
|
||||
$authorId = $profile->getId();
|
||||
$authorName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ('' === $authorName) {
|
||||
$authorName = $profile->getEmail() ?? 'Inconnu';
|
||||
}
|
||||
}
|
||||
|
||||
$comment = new Comment();
|
||||
$comment->setContent($content);
|
||||
$comment->setEntityType($entityType);
|
||||
$comment->setEntityId($entityId);
|
||||
$comment->setEntityName('' !== $entityName ? $entityName : null);
|
||||
$comment->setAuthorId($authorId);
|
||||
$comment->setAuthorName($authorName);
|
||||
|
||||
$this->em->persist($comment);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $comment->getId(),
|
||||
'content' => $comment->getContent(),
|
||||
'entityType' => $comment->getEntityType(),
|
||||
'entityId' => $comment->getEntityId(),
|
||||
'entityName' => $comment->getEntityName(),
|
||||
'authorName' => $comment->getAuthorName(),
|
||||
'status' => $comment->getStatus(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
67
src/Mcp/Tool/Comment/ListCommentsTool.php
Normal file
67
src/Mcp/Tool/Comment/ListCommentsTool.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Comment;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_comments',
|
||||
description: 'List comments for a given entity (machine, piece, composant, product…) with pagination. Filter by entityType and entityId.',
|
||||
)]
|
||||
class ListCommentsTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $entityType, string $entityId, int $page = 1, int $limit = 30): array
|
||||
{
|
||||
$p = $this->paginationParams($page, $limit);
|
||||
|
||||
$repo = $this->em->getRepository(Comment::class);
|
||||
|
||||
$total = (int) $repo->createQueryBuilder('c')
|
||||
->select('COUNT(c.id)')
|
||||
->andWhere('c.entityType = :entityType')
|
||||
->andWhere('c.entityId = :entityId')
|
||||
->setParameter('entityType', $entityType)
|
||||
->setParameter('entityId', $entityId)
|
||||
->getQuery()
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
|
||||
$items = $repo->createQueryBuilder('c')
|
||||
->select(
|
||||
'c.id',
|
||||
'c.content',
|
||||
'c.entityType',
|
||||
'c.entityId',
|
||||
'c.entityName',
|
||||
'c.authorName',
|
||||
'c.authorId',
|
||||
'c.status',
|
||||
'c.resolvedByName',
|
||||
'c.resolvedAt',
|
||||
'c.createdAt',
|
||||
)
|
||||
->andWhere('c.entityType = :entityType')
|
||||
->andWhere('c.entityId = :entityId')
|
||||
->setParameter('entityType', $entityType)
|
||||
->setParameter('entityId', $entityId)
|
||||
->orderBy('c.createdAt', 'DESC')
|
||||
->setFirstResult($p['offset'])
|
||||
->setMaxResults($p['limit'])
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
|
||||
}
|
||||
}
|
||||
65
src/Mcp/Tool/Comment/ResolveCommentTool.php
Normal file
65
src/Mcp/Tool/Comment/ResolveCommentTool.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Comment;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ProfileRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'resolve_comment',
|
||||
description: 'Mark a comment as resolved. Sets status to "resolved" with resolver info. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class ResolveCommentTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly ProfileRepository $profiles,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $commentId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$comment = $this->em->getRepository(Comment::class)->find($commentId);
|
||||
if (!$comment) {
|
||||
$this->mcpError('NotFound', 'Commentaire introuvable.');
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
$profile = $user ? $this->profiles->find($user->getUserIdentifier()) : null;
|
||||
|
||||
$resolverName = 'Inconnu';
|
||||
$resolverId = null;
|
||||
if ($profile) {
|
||||
$resolverId = $profile->getId();
|
||||
$resolverName = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
|
||||
if ('' === $resolverName) {
|
||||
$resolverName = $profile->getEmail() ?? 'Inconnu';
|
||||
}
|
||||
}
|
||||
|
||||
$comment->setStatus('resolved');
|
||||
$comment->setResolvedById($resolverId);
|
||||
$comment->setResolvedByName($resolverName);
|
||||
$comment->setResolvedAt(new DateTimeImmutable());
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $comment->getId(),
|
||||
'status' => $comment->getStatus(),
|
||||
'resolvedById' => $comment->getResolvedById(),
|
||||
'resolvedByName' => $comment->getResolvedByName(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
37
src/Mcp/Tool/Comment/UnresolvedCountTool.php
Normal file
37
src/Mcp/Tool/Comment/UnresolvedCountTool.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Comment;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_unresolved_comments_count',
|
||||
description: 'Get the total count of unresolved (open) comments across all entities.',
|
||||
)]
|
||||
class UnresolvedCountTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function __invoke(): array
|
||||
{
|
||||
$count = (int) $this->em->getRepository(Comment::class)
|
||||
->createQueryBuilder('c')
|
||||
->select('COUNT(c.id)')
|
||||
->andWhere('c.status = :status')
|
||||
->setParameter('status', 'open')
|
||||
->getQuery()
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
|
||||
return $this->jsonResponse(['count' => $count]);
|
||||
}
|
||||
}
|
||||
80
src/Mcp/Tool/Composant/CreateComposantTool.php
Normal file
80
src/Mcp/Tool/Composant/CreateComposantTool.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Composant;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'create_composant',
|
||||
description: 'Create a new composant. prix must be a string (e.g. "12.50"). Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class CreateComposantTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string[] $constructeurIds
|
||||
*/
|
||||
public function __invoke(
|
||||
string $name,
|
||||
string $reference = '',
|
||||
string $description = '',
|
||||
string $prix = '',
|
||||
string $modelTypeId = '',
|
||||
array $constructeurIds = [],
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$composant = new Composant();
|
||||
$composant->setName($name);
|
||||
|
||||
if ('' !== $reference) {
|
||||
$composant->setReference($reference);
|
||||
}
|
||||
if ('' !== $description) {
|
||||
$composant->setDescription($description);
|
||||
}
|
||||
if ('' !== $prix) {
|
||||
$composant->setPrix($prix);
|
||||
}
|
||||
|
||||
if ('' !== $modelTypeId) {
|
||||
$modelType = $this->modelTypes->find($modelTypeId);
|
||||
if (!$modelType) {
|
||||
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
|
||||
}
|
||||
$composant->setTypeComposant($modelType);
|
||||
}
|
||||
|
||||
foreach ($constructeurIds as $cId) {
|
||||
$c = $this->constructeurs->find($cId);
|
||||
if (!$c) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$cId}");
|
||||
}
|
||||
$composant->addConstructeur($c);
|
||||
}
|
||||
|
||||
$this->em->persist($composant);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $composant->getId(),
|
||||
'name' => $composant->getName(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
src/Mcp/Tool/Composant/DeleteComposantTool.php
Normal file
42
src/Mcp/Tool/Composant/DeleteComposantTool.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Composant;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ComposantRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'delete_composant',
|
||||
description: 'Delete a composant by ID. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class DeleteComposantTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ComposantRepository $composants,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $composantId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$composant = $this->composants->find($composantId);
|
||||
|
||||
if (!$composant) {
|
||||
$this->mcpError('not_found', "Composant not found: {$composantId}");
|
||||
}
|
||||
|
||||
$this->em->remove($composant);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $composantId]);
|
||||
}
|
||||
}
|
||||
59
src/Mcp/Tool/Composant/GetComposantTool.php
Normal file
59
src/Mcp/Tool/Composant/GetComposantTool.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Composant;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ComposantRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_composant',
|
||||
description: 'Get a single composant by ID with all its details, including typeComposant and constructeurs.',
|
||||
)]
|
||||
class GetComposantTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ComposantRepository $composants,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $composantId): array
|
||||
{
|
||||
$composant = $this->composants->find($composantId);
|
||||
|
||||
if (!$composant) {
|
||||
$this->mcpError('not_found', "Composant not found: {$composantId}");
|
||||
}
|
||||
|
||||
$constructeurs = [];
|
||||
foreach ($composant->getConstructeurs() as $c) {
|
||||
$constructeurs[] = [
|
||||
'id' => $c->getId(),
|
||||
'name' => $c->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
$typeComposant = null;
|
||||
if ($composant->getTypeComposant()) {
|
||||
$typeComposant = [
|
||||
'id' => $composant->getTypeComposant()->getId(),
|
||||
'name' => $composant->getTypeComposant()->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $composant->getId(),
|
||||
'name' => $composant->getName(),
|
||||
'reference' => $composant->getReference(),
|
||||
'description' => $composant->getDescription(),
|
||||
'prix' => $composant->getPrix(),
|
||||
'typeComposant' => $typeComposant,
|
||||
'constructeurs' => $constructeurs,
|
||||
'createdAt' => $composant->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'updatedAt' => $composant->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
src/Mcp/Tool/Composant/ListComposantsTool.php
Normal file
55
src/Mcp/Tool/Composant/ListComposantsTool.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Composant;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ComposantRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_composants',
|
||||
description: 'List composants with pagination. Filterable by name or reference.',
|
||||
)]
|
||||
class ListComposantsTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ComposantRepository $composants,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): array
|
||||
{
|
||||
$p = $this->paginationParams($page, $limit);
|
||||
|
||||
$countQb = $this->composants->createQueryBuilder('c')
|
||||
->select('COUNT(c.id)')
|
||||
;
|
||||
|
||||
$qb = $this->composants->createQueryBuilder('c')
|
||||
->select('c.id', 'c.name', 'c.reference', 'c.prix')
|
||||
->orderBy('c.name', 'ASC')
|
||||
;
|
||||
|
||||
if ('' !== $search) {
|
||||
$countQb->andWhere('LOWER(c.name) LIKE LOWER(:search) OR LOWER(c.reference) LIKE LOWER(:search)')
|
||||
->setParameter('search', "%{$search}%")
|
||||
;
|
||||
$qb->andWhere('LOWER(c.name) LIKE LOWER(:search) OR LOWER(c.reference) LIKE LOWER(:search)')
|
||||
->setParameter('search', "%{$search}%")
|
||||
;
|
||||
}
|
||||
|
||||
$total = (int) $countQb->getQuery()->getSingleScalarResult();
|
||||
|
||||
$items = $qb->setFirstResult($p['offset'])
|
||||
->setMaxResults($p['limit'])
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
|
||||
}
|
||||
}
|
||||
93
src/Mcp/Tool/Composant/UpdateComposantTool.php
Normal file
93
src/Mcp/Tool/Composant/UpdateComposantTool.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Composant;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'update_composant',
|
||||
description: 'Update an existing composant. Only provided fields are changed. prix must be a string. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class UpdateComposantTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ComposantRepository $composants,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param null|string[] $constructeurIds
|
||||
*/
|
||||
public function __invoke(
|
||||
string $composantId,
|
||||
?string $name = null,
|
||||
?string $reference = null,
|
||||
?string $description = null,
|
||||
?string $prix = null,
|
||||
?string $modelTypeId = null,
|
||||
?array $constructeurIds = null,
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$composant = $this->composants->find($composantId);
|
||||
|
||||
if (!$composant) {
|
||||
$this->mcpError('not_found', "Composant not found: {$composantId}");
|
||||
}
|
||||
|
||||
if (null !== $name) {
|
||||
$composant->setName($name);
|
||||
}
|
||||
if (null !== $reference) {
|
||||
$composant->setReference($reference);
|
||||
}
|
||||
if (null !== $description) {
|
||||
$composant->setDescription($description);
|
||||
}
|
||||
if (null !== $prix) {
|
||||
$composant->setPrix($prix);
|
||||
}
|
||||
|
||||
if (null !== $modelTypeId) {
|
||||
if ('' === $modelTypeId) {
|
||||
$composant->setTypeComposant(null);
|
||||
} else {
|
||||
$modelType = $this->modelTypes->find($modelTypeId);
|
||||
if (!$modelType) {
|
||||
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
|
||||
}
|
||||
$composant->setTypeComposant($modelType);
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $constructeurIds) {
|
||||
foreach ($composant->getConstructeurs()->toArray() as $existing) {
|
||||
$composant->removeConstructeur($existing);
|
||||
}
|
||||
foreach ($constructeurIds as $cId) {
|
||||
$c = $this->constructeurs->find($cId);
|
||||
if (!$c) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$cId}");
|
||||
}
|
||||
$composant->addConstructeur($c);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['id' => $composant->getId(), 'name' => $composant->getName()]);
|
||||
}
|
||||
}
|
||||
46
src/Mcp/Tool/Constructeur/CreateConstructeurTool.php
Normal file
46
src/Mcp/Tool/Constructeur/CreateConstructeurTool.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Constructeur;
|
||||
|
||||
use App\Entity\Constructeur;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'create_constructeur',
|
||||
description: 'Create a new constructeur (manufacturer/supplier). Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class CreateConstructeurTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $name,
|
||||
string $email = '',
|
||||
string $phone = '',
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$constructeur = new Constructeur();
|
||||
$constructeur->setName($name);
|
||||
$constructeur->setEmail('' !== $email ? $email : null);
|
||||
$constructeur->setPhone('' !== $phone ? $phone : null);
|
||||
|
||||
$this->em->persist($constructeur);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $constructeur->getId(),
|
||||
'name' => $constructeur->getName(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
src/Mcp/Tool/Constructeur/DeleteConstructeurTool.php
Normal file
42
src/Mcp/Tool/Constructeur/DeleteConstructeurTool.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Constructeur;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'delete_constructeur',
|
||||
description: 'Delete a constructeur by ID. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class DeleteConstructeurTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $constructeurId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$constructeur = $this->constructeurs->find($constructeurId);
|
||||
|
||||
if (!$constructeur) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
|
||||
}
|
||||
|
||||
$this->em->remove($constructeur);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $constructeurId]);
|
||||
}
|
||||
}
|
||||
40
src/Mcp/Tool/Constructeur/GetConstructeurTool.php
Normal file
40
src/Mcp/Tool/Constructeur/GetConstructeurTool.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Constructeur;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_constructeur',
|
||||
description: 'Get a single constructeur (manufacturer/supplier) by ID with all its details.',
|
||||
)]
|
||||
class GetConstructeurTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $constructeurId): array
|
||||
{
|
||||
$constructeur = $this->constructeurs->find($constructeurId);
|
||||
|
||||
if (!$constructeur) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $constructeur->getId(),
|
||||
'name' => $constructeur->getName(),
|
||||
'email' => $constructeur->getEmail(),
|
||||
'phone' => $constructeur->getPhone(),
|
||||
'createdAt' => $constructeur->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
src/Mcp/Tool/Constructeur/ListConstructeursTool.php
Normal file
55
src/Mcp/Tool/Constructeur/ListConstructeursTool.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Constructeur;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_constructeurs',
|
||||
description: 'List all constructeurs (manufacturers/suppliers) with pagination. Filterable by name.',
|
||||
)]
|
||||
class ListConstructeursTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): array
|
||||
{
|
||||
$p = $this->paginationParams($page, $limit);
|
||||
|
||||
$countQb = $this->constructeurs->createQueryBuilder('c')
|
||||
->select('COUNT(c.id)')
|
||||
;
|
||||
|
||||
$qb = $this->constructeurs->createQueryBuilder('c')
|
||||
->select('c.id', 'c.name', 'c.email', 'c.phone')
|
||||
->orderBy('c.name', 'ASC')
|
||||
;
|
||||
|
||||
if ('' !== $search) {
|
||||
$countQb->andWhere('LOWER(c.name) LIKE LOWER(:search)')
|
||||
->setParameter('search', "%{$search}%")
|
||||
;
|
||||
$qb->andWhere('LOWER(c.name) LIKE LOWER(:search)')
|
||||
->setParameter('search', "%{$search}%")
|
||||
;
|
||||
}
|
||||
|
||||
$total = (int) $countQb->getQuery()->getSingleScalarResult();
|
||||
|
||||
$items = $qb->setFirstResult($p['offset'])
|
||||
->setMaxResults($p['limit'])
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
|
||||
}
|
||||
}
|
||||
55
src/Mcp/Tool/Constructeur/UpdateConstructeurTool.php
Normal file
55
src/Mcp/Tool/Constructeur/UpdateConstructeurTool.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Constructeur;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'update_constructeur',
|
||||
description: 'Update an existing constructeur. Only provided fields are changed. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class UpdateConstructeurTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $constructeurId,
|
||||
?string $name = null,
|
||||
?string $email = null,
|
||||
?string $phone = null,
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$constructeur = $this->constructeurs->find($constructeurId);
|
||||
|
||||
if (!$constructeur) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
|
||||
}
|
||||
|
||||
if (null !== $name) {
|
||||
$constructeur->setName($name);
|
||||
}
|
||||
if (null !== $email) {
|
||||
$constructeur->setEmail($email);
|
||||
}
|
||||
if (null !== $phone) {
|
||||
$constructeur->setPhone($phone);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['id' => $constructeur->getId(), 'name' => $constructeur->getName()]);
|
||||
}
|
||||
}
|
||||
41
src/Mcp/Tool/CustomField/DeleteCustomFieldValueTool.php
Normal file
41
src/Mcp/Tool/CustomField/DeleteCustomFieldValueTool.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\CustomField;
|
||||
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'delete_custom_field_value',
|
||||
description: 'Delete a custom field value by ID. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class DeleteCustomFieldValueTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $customFieldValueId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$cfv = $this->em->getRepository(CustomFieldValue::class)->find($customFieldValueId);
|
||||
|
||||
if (null === $cfv) {
|
||||
$this->mcpError('not_found', "CustomFieldValue not found: {$customFieldValueId}");
|
||||
}
|
||||
|
||||
$this->em->remove($cfv);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $customFieldValueId]);
|
||||
}
|
||||
}
|
||||
61
src/Mcp/Tool/CustomField/ListCustomFieldValuesTool.php
Normal file
61
src/Mcp/Tool/CustomField/ListCustomFieldValuesTool.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\CustomField;
|
||||
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_custom_field_values',
|
||||
description: 'List all custom field values for a given entity (machine, composant, piece or product). Returns each value with its custom field name and type.',
|
||||
)]
|
||||
class ListCustomFieldValuesTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
private const ALLOWED_TYPES = ['machine', 'composant', 'piece', 'product'];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $entityType, string $entityId): array
|
||||
{
|
||||
$entityType = strtolower($entityType);
|
||||
|
||||
if (!in_array($entityType, self::ALLOWED_TYPES, true)) {
|
||||
$this->mcpError('validation', "entityType must be one of: machine, composant, piece, product. Got '{$entityType}'.");
|
||||
}
|
||||
|
||||
$rows = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'cfv.id',
|
||||
'cfv.value',
|
||||
'cf.id AS customFieldId',
|
||||
'cf.name AS customFieldName',
|
||||
'cf.type AS customFieldType',
|
||||
'cf.required AS customFieldRequired',
|
||||
'cfv.createdAt',
|
||||
'cfv.updatedAt',
|
||||
)
|
||||
->from(CustomFieldValue::class, 'cfv')
|
||||
->join('cfv.customField', 'cf')
|
||||
->where("IDENTITY(cfv.{$entityType}) = :entityId")
|
||||
->setParameter('entityId', $entityId)
|
||||
->orderBy('cf.name', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return $this->jsonResponse([
|
||||
'entityType' => $entityType,
|
||||
'entityId' => $entityId,
|
||||
'values' => $rows,
|
||||
'total' => count($rows),
|
||||
]);
|
||||
}
|
||||
}
|
||||
114
src/Mcp/Tool/CustomField/UpsertCustomFieldValuesTool.php
Normal file
114
src/Mcp/Tool/CustomField/UpsertCustomFieldValuesTool.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\CustomField;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Machine;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\Product;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'upsert_custom_field_values',
|
||||
description: 'Create or update custom field values for a given entity. Each entry in the fields array needs customFieldId and value. If a value already exists for that custom field + entity, it is updated; otherwise a new one is created. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class UpsertCustomFieldValuesTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
private const ALLOWED_TYPES = ['machine', 'composant', 'piece', 'product'];
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $entityType, string $entityId, array $fields): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$entityType = strtolower($entityType);
|
||||
|
||||
if (!in_array($entityType, self::ALLOWED_TYPES, true)) {
|
||||
$this->mcpError('validation', "entityType must be one of: machine, composant, piece, product. Got '{$entityType}'.");
|
||||
}
|
||||
|
||||
$entityClass = match ($entityType) {
|
||||
'machine' => Machine::class,
|
||||
'composant' => Composant::class,
|
||||
'piece' => Piece::class,
|
||||
'product' => Product::class,
|
||||
};
|
||||
|
||||
$entity = $this->em->getRepository($entityClass)->find($entityId);
|
||||
|
||||
if (null === $entity) {
|
||||
$this->mcpError('not_found', ucfirst($entityType)." not found: {$entityId}");
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($fields as $fieldEntry) {
|
||||
$customFieldId = $fieldEntry['customFieldId'] ?? null;
|
||||
$value = $fieldEntry['value'] ?? '';
|
||||
|
||||
if (null === $customFieldId) {
|
||||
$this->mcpError('validation', 'Each field entry must have a customFieldId.');
|
||||
}
|
||||
|
||||
$customField = $this->em->getRepository(CustomField::class)->find($customFieldId);
|
||||
|
||||
if (null === $customField) {
|
||||
$this->mcpError('not_found', "CustomField not found: {$customFieldId}");
|
||||
}
|
||||
|
||||
$existing = $this->em->getRepository(CustomFieldValue::class)->findOneBy([
|
||||
'customField' => $customField,
|
||||
$entityType => $entity,
|
||||
]);
|
||||
|
||||
if (null !== $existing) {
|
||||
$existing->setValue((string) $value);
|
||||
$results[] = [
|
||||
'id' => $existing->getId(),
|
||||
'customFieldId' => $customField->getId(),
|
||||
'value' => (string) $value,
|
||||
'action' => 'updated',
|
||||
];
|
||||
} else {
|
||||
$cfv = new CustomFieldValue();
|
||||
$cfv->setCustomField($customField);
|
||||
$cfv->setValue((string) $value);
|
||||
|
||||
$setter = 'set'.ucfirst($entityType);
|
||||
$cfv->{$setter}($entity);
|
||||
|
||||
$this->em->persist($cfv);
|
||||
$this->em->flush();
|
||||
|
||||
$results[] = [
|
||||
'id' => $cfv->getId(),
|
||||
'customFieldId' => $customField->getId(),
|
||||
'value' => (string) $value,
|
||||
'action' => 'created',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'entityType' => $entityType,
|
||||
'entityId' => $entityId,
|
||||
'results' => $results,
|
||||
'total' => count($results),
|
||||
]);
|
||||
}
|
||||
}
|
||||
50
src/Mcp/Tool/DashboardStatsTool.php
Normal file
50
src/Mcp/Tool/DashboardStatsTool.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool;
|
||||
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\SiteRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_dashboard_stats',
|
||||
description: 'Get global inventory statistics: count of machines, pieces, composants, products, sites, and unresolved comments. Takes no parameters.',
|
||||
)]
|
||||
class DashboardStatsTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly PieceRepository $pieces,
|
||||
private readonly ComposantRepository $composants,
|
||||
private readonly ProductRepository $products,
|
||||
private readonly SiteRepository $sites,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function __invoke(): array
|
||||
{
|
||||
$unresolvedComments = (int) $this->em->createQuery(
|
||||
"SELECT COUNT(c.id) FROM App\\Entity\\Comment c WHERE c.status = 'open'"
|
||||
)->getSingleScalarResult();
|
||||
|
||||
return [
|
||||
new TextContent(
|
||||
text: json_encode([
|
||||
'machines' => $this->machines->count([]),
|
||||
'pieces' => $this->pieces->count([]),
|
||||
'composants' => $this->composants->count([]),
|
||||
'products' => $this->products->count([]),
|
||||
'sites' => $this->sites->count([]),
|
||||
'unresolvedComments' => $unresolvedComments,
|
||||
], JSON_THROW_ON_ERROR)
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
42
src/Mcp/Tool/Document/DeleteDocumentTool.php
Normal file
42
src/Mcp/Tool/Document/DeleteDocumentTool.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Document;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\DocumentRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'delete_document',
|
||||
description: 'Delete a document by ID. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class DeleteDocumentTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly DocumentRepository $documents,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $documentId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$document = $this->documents->find($documentId);
|
||||
|
||||
if (!$document) {
|
||||
$this->mcpError('not_found', "Document not found: {$documentId}");
|
||||
}
|
||||
|
||||
$this->em->remove($document);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $documentId]);
|
||||
}
|
||||
}
|
||||
62
src/Mcp/Tool/Document/ListDocumentsTool.php
Normal file
62
src/Mcp/Tool/Document/ListDocumentsTool.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Document;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\DocumentRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_documents',
|
||||
description: 'List documents attached to a given entity. entityType must be one of: site, machine, composant, piece, product.',
|
||||
)]
|
||||
class ListDocumentsTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
private const ENTITY_FIELDS = [
|
||||
'site' => 'site',
|
||||
'machine' => 'machine',
|
||||
'composant' => 'composant',
|
||||
'piece' => 'piece',
|
||||
'product' => 'product',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly DocumentRepository $documents,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $entityType, string $entityId): array
|
||||
{
|
||||
if (!isset(self::ENTITY_FIELDS[$entityType])) {
|
||||
$this->mcpError('validation', "Invalid entityType '{$entityType}'. Must be one of: site, machine, composant, piece, product.");
|
||||
}
|
||||
|
||||
$field = self::ENTITY_FIELDS[$entityType];
|
||||
|
||||
$docs = $this->documents->findBy([$field => $entityId], ['createdAt' => 'DESC']);
|
||||
|
||||
$items = [];
|
||||
foreach ($docs as $doc) {
|
||||
$items[] = [
|
||||
'id' => $doc->getId(),
|
||||
'name' => $doc->getName(),
|
||||
'filename' => $doc->getFilename(),
|
||||
'fileUrl' => '/api/documents/'.$doc->getId().'/file',
|
||||
'downloadUrl' => '/api/documents/'.$doc->getId().'/download',
|
||||
'mimeType' => $doc->getMimeType(),
|
||||
'size' => $doc->getSize(),
|
||||
'createdAt' => $doc->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'entityType' => $entityType,
|
||||
'entityId' => $entityId,
|
||||
'items' => $items,
|
||||
'total' => count($items),
|
||||
]);
|
||||
}
|
||||
}
|
||||
57
src/Mcp/Tool/EntityHistoryTool.php
Normal file
57
src/Mcp/Tool/EntityHistoryTool.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use DateTimeInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_entity_history',
|
||||
description: 'Get the audit history for a specific entity (machine, piece, composant, product). Returns list of changes with diffs.',
|
||||
)]
|
||||
class EntityHistoryTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
private const VALID_TYPES = ['machine', 'piece', 'composant', 'product'];
|
||||
|
||||
public function __construct(
|
||||
private readonly AuditLogRepository $auditLogs,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $entityType, string $entityId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_VIEWER');
|
||||
|
||||
if (!in_array($entityType, self::VALID_TYPES, true)) {
|
||||
$this->mcpError('Validation', sprintf(
|
||||
'Invalid entityType "%s". Must be one of: %s',
|
||||
$entityType,
|
||||
implode(', ', self::VALID_TYPES),
|
||||
));
|
||||
}
|
||||
|
||||
$logs = $this->auditLogs->findEntityHistory($entityType, $entityId, 200);
|
||||
|
||||
$items = array_map(
|
||||
static fn ($log) => [
|
||||
'id' => $log->getId(),
|
||||
'action' => $log->getAction(),
|
||||
'diff' => $log->getDiff(),
|
||||
'actorProfileId' => $log->getActorProfileId(),
|
||||
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
|
||||
],
|
||||
$logs,
|
||||
);
|
||||
|
||||
return $this->jsonResponse([
|
||||
'items' => array_values($items),
|
||||
'total' => count($items),
|
||||
]);
|
||||
}
|
||||
}
|
||||
162
src/Mcp/Tool/Machine/AddMachineLinksTool.php
Normal file
162
src/Mcp/Tool/Machine/AddMachineLinksTool.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Entity\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
use App\Entity\MachineProductLink;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'add_machine_links',
|
||||
description: 'Add one or more links (composant, piece, product) to a machine. Each link specifies a type, entityId, and optional parentLinkId / overrides. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class AddMachineLinksTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly ComposantRepository $composants,
|
||||
private readonly PieceRepository $pieces,
|
||||
private readonly ProductRepository $products,
|
||||
private readonly MachineComponentLinkRepository $componentLinks,
|
||||
private readonly MachinePieceLinkRepository $pieceLinks,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $machineId, array $links): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$machine = $this->machines->find($machineId);
|
||||
if (null === $machine) {
|
||||
$this->mcpError('NotFound', "Machine {$machineId} not found.");
|
||||
}
|
||||
|
||||
$created = [];
|
||||
|
||||
foreach ($links as $linkData) {
|
||||
$type = $linkData['type'] ?? '';
|
||||
$entityId = $linkData['entityId'] ?? '';
|
||||
|
||||
switch ($type) {
|
||||
case 'composant':
|
||||
$composant = $this->composants->find($entityId);
|
||||
if (null === $composant) {
|
||||
$this->mcpError('NotFound', "Composant {$entityId} not found.");
|
||||
}
|
||||
|
||||
$link = new MachineComponentLink();
|
||||
$link->setMachine($machine);
|
||||
$link->setComposant($composant);
|
||||
|
||||
if (!empty($linkData['parentLinkId'])) {
|
||||
$parent = $this->componentLinks->find($linkData['parentLinkId']);
|
||||
if (null !== $parent) {
|
||||
$link->setParentLink($parent);
|
||||
}
|
||||
}
|
||||
if (isset($linkData['nameOverride'])) {
|
||||
$link->setNameOverride($linkData['nameOverride']);
|
||||
}
|
||||
if (isset($linkData['referenceOverride'])) {
|
||||
$link->setReferenceOverride($linkData['referenceOverride']);
|
||||
}
|
||||
if (isset($linkData['prixOverride'])) {
|
||||
$link->setPrixOverride($linkData['prixOverride']);
|
||||
}
|
||||
|
||||
$this->em->persist($link);
|
||||
$created[] = ['id' => $link->getId(), 'type' => 'composant', 'entityId' => $entityId];
|
||||
|
||||
break;
|
||||
|
||||
case 'piece':
|
||||
$piece = $this->pieces->find($entityId);
|
||||
if (null === $piece) {
|
||||
$this->mcpError('NotFound', "Piece {$entityId} not found.");
|
||||
}
|
||||
|
||||
$link = new MachinePieceLink();
|
||||
$link->setMachine($machine);
|
||||
$link->setPiece($piece);
|
||||
$link->setQuantity((int) ($linkData['quantity'] ?? 1));
|
||||
|
||||
if (!empty($linkData['parentLinkId'])) {
|
||||
$parent = $this->componentLinks->find($linkData['parentLinkId']);
|
||||
if (null !== $parent) {
|
||||
$link->setParentLink($parent);
|
||||
}
|
||||
}
|
||||
if (isset($linkData['nameOverride'])) {
|
||||
$link->setNameOverride($linkData['nameOverride']);
|
||||
}
|
||||
if (isset($linkData['referenceOverride'])) {
|
||||
$link->setReferenceOverride($linkData['referenceOverride']);
|
||||
}
|
||||
if (isset($linkData['prixOverride'])) {
|
||||
$link->setPrixOverride($linkData['prixOverride']);
|
||||
}
|
||||
|
||||
$this->em->persist($link);
|
||||
$created[] = ['id' => $link->getId(), 'type' => 'piece', 'entityId' => $entityId];
|
||||
|
||||
break;
|
||||
|
||||
case 'product':
|
||||
$product = $this->products->find($entityId);
|
||||
if (null === $product) {
|
||||
$this->mcpError('NotFound', "Product {$entityId} not found.");
|
||||
}
|
||||
|
||||
$link = new MachineProductLink();
|
||||
$link->setMachine($machine);
|
||||
$link->setProduct($product);
|
||||
|
||||
if (!empty($linkData['parentLinkId'])) {
|
||||
$parentProduct = $this->em->getRepository(MachineProductLink::class)->find($linkData['parentLinkId']);
|
||||
if (null !== $parentProduct) {
|
||||
$link->setParentLink($parentProduct);
|
||||
}
|
||||
}
|
||||
if (!empty($linkData['parentComponentLinkId'])) {
|
||||
$parentComp = $this->componentLinks->find($linkData['parentComponentLinkId']);
|
||||
if (null !== $parentComp) {
|
||||
$link->setParentComponentLink($parentComp);
|
||||
}
|
||||
}
|
||||
if (!empty($linkData['parentPieceLinkId'])) {
|
||||
$parentPiece = $this->pieceLinks->find($linkData['parentPieceLinkId']);
|
||||
if (null !== $parentPiece) {
|
||||
$link->setParentPieceLink($parentPiece);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->persist($link);
|
||||
$created[] = ['id' => $link->getId(), 'type' => 'product', 'entityId' => $entityId];
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
$this->mcpError('Validation', "Unknown link type '{$type}'. Expected composant, piece, or product.");
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['created' => $created]);
|
||||
}
|
||||
}
|
||||
223
src/Mcp/Tool/Machine/CloneMachineTool.php
Normal file
223
src/Mcp/Tool/Machine/CloneMachineTool.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Machine;
|
||||
use App\Entity\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
use App\Entity\MachineProductLink;
|
||||
use App\Entity\Site;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use App\Repository\MachineProductLinkRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'clone_machine',
|
||||
description: 'Clone an existing machine with all its links (components, pieces, products), custom fields, and constructeurs. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class CloneMachineTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly MachineRepository $machineRepository,
|
||||
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
|
||||
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
|
||||
private readonly MachineProductLinkRepository $machineProductLinkRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $machineId,
|
||||
string $name,
|
||||
string $siteId,
|
||||
string $reference = '',
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$source = $this->machineRepository->find($machineId);
|
||||
if (!$source instanceof Machine) {
|
||||
$this->mcpError('not_found', "Machine not found: {$machineId}");
|
||||
}
|
||||
|
||||
$site = $this->entityManager->getRepository(Site::class)->find($siteId);
|
||||
if (!$site) {
|
||||
$this->mcpError('not_found', "Site not found: {$siteId}");
|
||||
}
|
||||
|
||||
// Create new machine
|
||||
$newMachine = new Machine();
|
||||
$newMachine->setName($name);
|
||||
$newMachine->setSite($site);
|
||||
if ('' !== $reference) {
|
||||
$newMachine->setReference($reference);
|
||||
}
|
||||
$newMachine->setPrix($source->getPrix());
|
||||
|
||||
// Copy constructeurs
|
||||
foreach ($source->getConstructeurs() as $constructeur) {
|
||||
$newMachine->getConstructeurs()->add($constructeur);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($newMachine);
|
||||
|
||||
// Copy custom fields and values
|
||||
$this->cloneCustomFields($source, $newMachine);
|
||||
|
||||
// Copy component links (preserving hierarchy with two-pass)
|
||||
$componentLinkMap = $this->cloneComponentLinks($source, $newMachine);
|
||||
|
||||
// Copy piece links
|
||||
$pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap);
|
||||
|
||||
// Copy product links
|
||||
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $newMachine->getId(),
|
||||
'name' => $newMachine->getName(),
|
||||
'reference' => $newMachine->getReference(),
|
||||
'siteId' => $site->getId(),
|
||||
'clonedFrom' => $source->getId(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function cloneCustomFields(Machine $source, Machine $target): void
|
||||
{
|
||||
foreach ($source->getCustomFields() as $cf) {
|
||||
$newCf = new CustomField();
|
||||
$newCf->setName($cf->getName());
|
||||
$newCf->setType($cf->getType());
|
||||
$newCf->setRequired($cf->isRequired());
|
||||
$newCf->setDefaultValue($cf->getDefaultValue());
|
||||
$newCf->setOptions($cf->getOptions());
|
||||
$newCf->setOrderIndex($cf->getOrderIndex());
|
||||
$newCf->setMachine($target);
|
||||
$this->entityManager->persist($newCf);
|
||||
}
|
||||
|
||||
foreach ($source->getCustomFieldValues() as $cfv) {
|
||||
$newValue = new CustomFieldValue();
|
||||
$newValue->setMachine($target);
|
||||
$newValue->setCustomField($cfv->getCustomField());
|
||||
$newValue->setValue($cfv->getValue());
|
||||
$this->entityManager->persist($newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, MachineComponentLink> Map of old link ID to new link
|
||||
*/
|
||||
private function cloneComponentLinks(Machine $source, Machine $target): array
|
||||
{
|
||||
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
// First pass: create all links without parent relationships
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachineComponentLink();
|
||||
$newLink->setMachine($target);
|
||||
$newLink->setComposant($link->getComposant());
|
||||
$newLink->setNameOverride($link->getNameOverride());
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
$newLink->setPrixOverride($link->getPrixOverride());
|
||||
$this->entityManager->persist($newLink);
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
// Second pass: set parent relationships
|
||||
foreach ($sourceLinks as $link) {
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($linkMap[$parent->getId()])) {
|
||||
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
|
||||
}
|
||||
}
|
||||
|
||||
return $linkMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, MachineComponentLink> $componentLinkMap
|
||||
*
|
||||
* @return array<string, MachinePieceLink> Map of old link ID to new link
|
||||
*/
|
||||
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array
|
||||
{
|
||||
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachinePieceLink();
|
||||
$newLink->setMachine($target);
|
||||
$newLink->setPiece($link->getPiece());
|
||||
$newLink->setNameOverride($link->getNameOverride());
|
||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||
$newLink->setPrixOverride($link->getPrixOverride());
|
||||
$newLink->setQuantity($link->getQuantity());
|
||||
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($componentLinkMap[$parent->getId()])) {
|
||||
$newLink->setParentLink($componentLinkMap[$parent->getId()]);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($newLink);
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
return $linkMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, MachineComponentLink> $componentLinkMap
|
||||
* @param array<string, MachinePieceLink> $pieceLinkMap
|
||||
*/
|
||||
private function cloneProductLinks(
|
||||
Machine $source,
|
||||
Machine $target,
|
||||
array $componentLinkMap,
|
||||
array $pieceLinkMap,
|
||||
): void {
|
||||
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source]);
|
||||
$linkMap = [];
|
||||
|
||||
// First pass: create all links
|
||||
foreach ($sourceLinks as $link) {
|
||||
$newLink = new MachineProductLink();
|
||||
$newLink->setMachine($target);
|
||||
$newLink->setProduct($link->getProduct());
|
||||
|
||||
$parentComponent = $link->getParentComponentLink();
|
||||
if ($parentComponent && isset($componentLinkMap[$parentComponent->getId()])) {
|
||||
$newLink->setParentComponentLink($componentLinkMap[$parentComponent->getId()]);
|
||||
}
|
||||
|
||||
$parentPiece = $link->getParentPieceLink();
|
||||
if ($parentPiece && isset($pieceLinkMap[$parentPiece->getId()])) {
|
||||
$newLink->setParentPieceLink($pieceLinkMap[$parentPiece->getId()]);
|
||||
}
|
||||
|
||||
$this->entityManager->persist($newLink);
|
||||
$linkMap[$link->getId()] = $newLink;
|
||||
}
|
||||
|
||||
// Second pass: set parent product link relationships
|
||||
foreach ($sourceLinks as $link) {
|
||||
$parent = $link->getParentLink();
|
||||
if ($parent && isset($linkMap[$parent->getId()])) {
|
||||
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
74
src/Mcp/Tool/Machine/CreateMachineTool.php
Normal file
74
src/Mcp/Tool/Machine/CreateMachineTool.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Entity\Machine;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use App\Repository\SiteRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'create_machine',
|
||||
description: 'Create a new machine. siteId is required. prix must be a string (e.g. "12.50"). Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class CreateMachineTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly SiteRepository $sites,
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string[] $constructeurIds
|
||||
*/
|
||||
public function __invoke(
|
||||
string $name,
|
||||
string $siteId,
|
||||
string $reference = '',
|
||||
string $prix = '',
|
||||
array $constructeurIds = [],
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$site = $this->sites->find($siteId);
|
||||
if (!$site) {
|
||||
$this->mcpError('not_found', "Site not found: {$siteId}");
|
||||
}
|
||||
|
||||
$machine = new Machine();
|
||||
$machine->setName($name);
|
||||
$machine->setSite($site);
|
||||
|
||||
if ('' !== $reference) {
|
||||
$machine->setReference($reference);
|
||||
}
|
||||
if ('' !== $prix) {
|
||||
$machine->setPrix($prix);
|
||||
}
|
||||
|
||||
foreach ($constructeurIds as $cId) {
|
||||
$c = $this->constructeurs->find($cId);
|
||||
if (!$c) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$cId}");
|
||||
}
|
||||
$machine->addConstructeur($c);
|
||||
}
|
||||
|
||||
$this->em->persist($machine);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $machine->getId(),
|
||||
'name' => $machine->getName(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
src/Mcp/Tool/Machine/DeleteMachineTool.php
Normal file
42
src/Mcp/Tool/Machine/DeleteMachineTool.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'delete_machine',
|
||||
description: 'Delete a machine by ID. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class DeleteMachineTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $machineId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$machine = $this->machines->find($machineId);
|
||||
|
||||
if (!$machine) {
|
||||
$this->mcpError('not_found', "Machine not found: {$machineId}");
|
||||
}
|
||||
|
||||
$this->em->remove($machine);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $machineId]);
|
||||
}
|
||||
}
|
||||
58
src/Mcp/Tool/Machine/GetMachineTool.php
Normal file
58
src/Mcp/Tool/Machine/GetMachineTool.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_machine',
|
||||
description: 'Get a single machine by ID with all its details, including site and constructeurs.',
|
||||
)]
|
||||
class GetMachineTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $machineId): array
|
||||
{
|
||||
$machine = $this->machines->find($machineId);
|
||||
|
||||
if (!$machine) {
|
||||
$this->mcpError('not_found', "Machine not found: {$machineId}");
|
||||
}
|
||||
|
||||
$constructeurs = [];
|
||||
foreach ($machine->getConstructeurs() as $c) {
|
||||
$constructeurs[] = [
|
||||
'id' => $c->getId(),
|
||||
'name' => $c->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
$site = null;
|
||||
if ($machine->getSite()) {
|
||||
$site = [
|
||||
'id' => $machine->getSite()->getId(),
|
||||
'name' => $machine->getSite()->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $machine->getId(),
|
||||
'name' => $machine->getName(),
|
||||
'reference' => $machine->getReference(),
|
||||
'prix' => $machine->getPrix(),
|
||||
'site' => $site,
|
||||
'constructeurs' => $constructeurs,
|
||||
'createdAt' => $machine->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'updatedAt' => $machine->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
70
src/Mcp/Tool/Machine/ListMachineLinksTool.php
Normal file
70
src/Mcp/Tool/Machine/ListMachineLinksTool.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use App\Repository\MachineProductLinkRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_machine_links',
|
||||
description: 'List all links (component, piece, product) for a given machine, grouped by type.',
|
||||
)]
|
||||
class ListMachineLinksTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly MachineComponentLinkRepository $componentLinks,
|
||||
private readonly MachinePieceLinkRepository $pieceLinks,
|
||||
private readonly MachineProductLinkRepository $productLinks,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $machineId): array
|
||||
{
|
||||
$machine = $this->machines->find($machineId);
|
||||
if (null === $machine) {
|
||||
$this->mcpError('NotFound', "Machine {$machineId} not found.");
|
||||
}
|
||||
|
||||
$compRows = $this->componentLinks->createQueryBuilder('cl')
|
||||
->select('cl.id', 'IDENTITY(cl.composant) AS entityId', 'IDENTITY(cl.parentLink) AS parentLinkId', 'cl.nameOverride', 'cl.referenceOverride', 'cl.prixOverride')
|
||||
->where('cl.machine = :machine')
|
||||
->setParameter('machine', $machine)
|
||||
->orderBy('cl.id', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
$pieceRows = $this->pieceLinks->createQueryBuilder('pl')
|
||||
->select('pl.id', 'IDENTITY(pl.piece) AS entityId', 'IDENTITY(pl.parentLink) AS parentLinkId', 'pl.nameOverride', 'pl.referenceOverride', 'pl.prixOverride', 'pl.quantity')
|
||||
->where('pl.machine = :machine')
|
||||
->setParameter('machine', $machine)
|
||||
->orderBy('pl.id', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
$productRows = $this->productLinks->createQueryBuilder('prl')
|
||||
->select('prl.id', 'IDENTITY(prl.product) AS entityId', 'IDENTITY(prl.parentLink) AS parentLinkId', 'IDENTITY(prl.parentComponentLink) AS parentComponentLinkId', 'IDENTITY(prl.parentPieceLink) AS parentPieceLinkId')
|
||||
->where('prl.machine = :machine')
|
||||
->setParameter('machine', $machine)
|
||||
->orderBy('prl.id', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return $this->jsonResponse([
|
||||
'machineId' => $machineId,
|
||||
'componentLinks' => $compRows,
|
||||
'pieceLinks' => $pieceRows,
|
||||
'productLinks' => $productRows,
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
src/Mcp/Tool/Machine/ListMachinesTool.php
Normal file
55
src/Mcp/Tool/Machine/ListMachinesTool.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_machines',
|
||||
description: 'List machines with pagination. Filterable by name or reference.',
|
||||
)]
|
||||
class ListMachinesTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): array
|
||||
{
|
||||
$p = $this->paginationParams($page, $limit);
|
||||
|
||||
$countQb = $this->machines->createQueryBuilder('m')
|
||||
->select('COUNT(m.id)')
|
||||
;
|
||||
|
||||
$qb = $this->machines->createQueryBuilder('m')
|
||||
->select('m.id', 'm.name', 'm.reference', 'm.prix')
|
||||
->orderBy('m.name', 'ASC')
|
||||
;
|
||||
|
||||
if ('' !== $search) {
|
||||
$countQb->andWhere('LOWER(m.name) LIKE LOWER(:search) OR LOWER(m.reference) LIKE LOWER(:search)')
|
||||
->setParameter('search', "%{$search}%")
|
||||
;
|
||||
$qb->andWhere('LOWER(m.name) LIKE LOWER(:search) OR LOWER(m.reference) LIKE LOWER(:search)')
|
||||
->setParameter('search', "%{$search}%")
|
||||
;
|
||||
}
|
||||
|
||||
$total = (int) $countQb->getQuery()->getSingleScalarResult();
|
||||
|
||||
$items = $qb->setFirstResult($p['offset'])
|
||||
->setMaxResults($p['limit'])
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return $this->paginatedResponse($items, $total, $p['page'], $p['limit']);
|
||||
}
|
||||
}
|
||||
469
src/Mcp/Tool/Machine/MachineStructureTool.php
Normal file
469
src/Mcp/Tool/Machine/MachineStructureTool.php
Normal file
@@ -0,0 +1,469 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\Machine;
|
||||
use App\Entity\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
use App\Entity\MachineProductLink;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\Product;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use App\Repository\MachineProductLinkRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_machine_structure',
|
||||
description: 'Get the full machine hierarchy: machine info, component links (with composant details, slots, overrides), piece links (with piece details, quantity, overrides), and product links.',
|
||||
)]
|
||||
class MachineStructureTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machineRepository,
|
||||
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
|
||||
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
|
||||
private readonly MachineProductLinkRepository $machineProductLinkRepository,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $machineId): array
|
||||
{
|
||||
$machine = $this->machineRepository->find($machineId);
|
||||
|
||||
if (!$machine instanceof Machine) {
|
||||
$this->mcpError('not_found', "Machine not found: {$machineId}");
|
||||
}
|
||||
|
||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine]);
|
||||
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
|
||||
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
|
||||
|
||||
return $this->jsonResponse($this->normalizeStructureResponse(
|
||||
$machine,
|
||||
$componentLinks,
|
||||
$pieceLinks,
|
||||
$productLinks,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param MachineComponentLink[] $componentLinks
|
||||
* @param MachinePieceLink[] $pieceLinks
|
||||
* @param MachineProductLink[] $productLinks
|
||||
*/
|
||||
private function normalizeStructureResponse(
|
||||
Machine $machine,
|
||||
array $componentLinks,
|
||||
array $pieceLinks,
|
||||
array $productLinks,
|
||||
): array {
|
||||
$normalizedComponentLinks = $this->normalizeComponentLinks($componentLinks);
|
||||
$componentIndex = $this->indexById($normalizedComponentLinks);
|
||||
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
|
||||
|
||||
$childIds = [];
|
||||
foreach ($normalizedComponentLinks as $link) {
|
||||
$parentId = $link['parentComponentLinkId'] ?? null;
|
||||
if ($parentId && isset($componentIndex[$parentId])) {
|
||||
$componentIndex[$parentId]['childLinks'][] = $link;
|
||||
$childIds[$link['id']] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
|
||||
|
||||
$rootComponents = array_filter(
|
||||
$componentIndex,
|
||||
static fn (array $link) => !isset($childIds[$link['id']]),
|
||||
);
|
||||
|
||||
return [
|
||||
'machine' => $this->normalizeMachine($machine),
|
||||
'componentLinks' => array_values($rootComponents),
|
||||
'pieceLinks' => $normalizedPieceLinks,
|
||||
'productLinks' => $this->normalizeProductLinks($productLinks),
|
||||
];
|
||||
}
|
||||
|
||||
private function attachPiecesToComponents(array &$componentIndex, array $pieceLinks): void
|
||||
{
|
||||
foreach ($pieceLinks as $pieceLink) {
|
||||
$parentId = $pieceLink['parentComponentLinkId'] ?? null;
|
||||
if ($parentId && isset($componentIndex[$parentId])) {
|
||||
$componentIndex[$parentId]['pieceLinks'][] = $pieceLink;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($componentIndex as &$component) {
|
||||
if (!empty($component['childLinks'])) {
|
||||
$this->attachPiecesToChildComponents($component['childLinks'], $pieceLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function attachPiecesToChildComponents(array &$childLinks, array $pieceLinks): void
|
||||
{
|
||||
foreach ($childLinks as &$child) {
|
||||
$childId = $child['id'] ?? null;
|
||||
if ($childId) {
|
||||
foreach ($pieceLinks as $pieceLink) {
|
||||
if (($pieceLink['parentComponentLinkId'] ?? null) === $childId) {
|
||||
$child['pieceLinks'][] = $pieceLink;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($child['childLinks'])) {
|
||||
$this->attachPiecesToChildComponents($child['childLinks'], $pieceLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeMachine(Machine $machine): array
|
||||
{
|
||||
$site = $machine->getSite();
|
||||
|
||||
return [
|
||||
'id' => $machine->getId(),
|
||||
'name' => $machine->getName(),
|
||||
'reference' => $machine->getReference(),
|
||||
'prix' => $machine->getPrix(),
|
||||
'siteId' => $site->getId(),
|
||||
'site' => [
|
||||
'id' => $site->getId(),
|
||||
'name' => $site->getName(),
|
||||
],
|
||||
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
|
||||
'customFields' => $this->normalizeCustomFields($machine->getCustomFields()),
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param MachineComponentLink[] $links
|
||||
*/
|
||||
private function normalizeComponentLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineComponentLink $link): array {
|
||||
$composant = $link->getComposant();
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'composantId' => $composant->getId(),
|
||||
'composant' => $this->normalizeComposant($composant),
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'childLinks' => [],
|
||||
'pieceLinks' => [],
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param MachinePieceLink[] $links
|
||||
*/
|
||||
private function normalizePieceLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachinePieceLink $link): array {
|
||||
$piece = $link->getPiece();
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'pieceId' => $piece->getId(),
|
||||
'piece' => $this->normalizePiece($piece),
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'quantity' => $this->resolvePieceQuantity($link),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
private function resolvePieceQuantity(MachinePieceLink $link): int
|
||||
{
|
||||
$parentLink = $link->getParentLink();
|
||||
|
||||
if (!$parentLink) {
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
$composant = $parentLink->getComposant();
|
||||
$piece = $link->getPiece();
|
||||
|
||||
foreach ($composant->getPieceSlots() as $slot) {
|
||||
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
|
||||
return $slot->getQuantity();
|
||||
}
|
||||
}
|
||||
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param MachineProductLink[] $links
|
||||
*/
|
||||
private function normalizeProductLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineProductLink $link): array {
|
||||
$product = $link->getProduct();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'productId' => $product->getId(),
|
||||
'product' => $this->normalizeProduct($product),
|
||||
'parentLinkId' => $link->getParentLink()?->getId(),
|
||||
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
|
||||
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
private function normalizeComposant(Composant $composant): array
|
||||
{
|
||||
$type = $composant->getTypeComposant();
|
||||
|
||||
return [
|
||||
'id' => $composant->getId(),
|
||||
'name' => $composant->getName(),
|
||||
'reference' => $composant->getReference(),
|
||||
'prix' => $composant->getPrix(),
|
||||
'typeComposantId' => $type?->getId(),
|
||||
'typeComposant' => $this->normalizeModelType($type),
|
||||
'productId' => $composant->getProduct()?->getId(),
|
||||
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
|
||||
'structure' => $this->buildStructureFromSlots($composant),
|
||||
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [],
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($composant->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
private function buildStructureFromSlots(Composant $composant): array
|
||||
{
|
||||
$pieces = [];
|
||||
foreach ($composant->getPieceSlots() as $slot) {
|
||||
$pieceData = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
];
|
||||
if ($slot->getSelectedPiece()) {
|
||||
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece());
|
||||
}
|
||||
$pieces[] = $pieceData;
|
||||
}
|
||||
|
||||
$subcomponents = [];
|
||||
foreach ($composant->getSubcomponentSlots() as $slot) {
|
||||
$subcomponents[] = [
|
||||
'alias' => $slot->getAlias(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'typeComposantId' => $slot->getTypeComposant()?->getId(),
|
||||
'selectedComponentId' => $slot->getSelectedComposant()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
$products = [];
|
||||
foreach ($composant->getProductSlots() as $slot) {
|
||||
$products[] = [
|
||||
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||
'familyCode' => $slot->getFamilyCode(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'pieces' => $pieces,
|
||||
'subcomponents' => $subcomponents,
|
||||
'products' => $products,
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizePiece(Piece $piece): array
|
||||
{
|
||||
$type = $piece->getTypePiece();
|
||||
|
||||
return [
|
||||
'id' => $piece->getId(),
|
||||
'name' => $piece->getName(),
|
||||
'reference' => $piece->getReference(),
|
||||
'prix' => $piece->getPrix(),
|
||||
'typePieceId' => $type?->getId(),
|
||||
'typePiece' => $this->normalizeModelType($type),
|
||||
'productId' => $piece->getProduct()?->getId(),
|
||||
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
|
||||
'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()),
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getPieceCustomFields()) : [],
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($piece->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeProduct(Product $product): array
|
||||
{
|
||||
$type = $product->getTypeProduct();
|
||||
|
||||
return [
|
||||
'id' => $product->getId(),
|
||||
'name' => $product->getName(),
|
||||
'reference' => $product->getReference(),
|
||||
'supplierPrice' => $product->getSupplierPrice(),
|
||||
'typeProductId' => $type?->getId(),
|
||||
'typeProduct' => $this->normalizeModelType($type),
|
||||
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
|
||||
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getProductCustomFields()) : [],
|
||||
'customFieldValues' => $this->normalizeCustomFieldValues($product->getCustomFieldValues()),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeModelType(?ModelType $type): ?array
|
||||
{
|
||||
if (!$type instanceof ModelType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $type->getId(),
|
||||
'name' => $type->getName(),
|
||||
'code' => $type->getCode(),
|
||||
'category' => $type->getCategory()->value,
|
||||
'structure' => $type->getStructure(),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeConstructeurs(Collection $constructeurs): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($constructeurs as $constructeur) {
|
||||
$items[] = [
|
||||
'id' => $constructeur->getId(),
|
||||
'name' => $constructeur->getName(),
|
||||
'email' => $constructeur->getEmail(),
|
||||
'phone' => $constructeur->getPhone(),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeCustomFields(Collection $customFields): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFields as $customField) {
|
||||
if (!$customField instanceof CustomField) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $customField->getId(),
|
||||
'name' => $customField->getName(),
|
||||
'type' => $customField->getType(),
|
||||
'required' => $customField->isRequired(),
|
||||
'options' => $customField->getOptions(),
|
||||
'defaultValue' => $customField->getDefaultValue(),
|
||||
'orderIndex' => $customField->getOrderIndex(),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeCustomFieldDefinitions(Collection $customFields): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFields as $cf) {
|
||||
if (!$cf instanceof CustomField) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeCustomFieldValues(Collection $customFieldValues): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFieldValues as $cfv) {
|
||||
if (!$cfv instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
$cf = $cfv->getCustomField();
|
||||
$items[] = [
|
||||
'id' => $cfv->getId(),
|
||||
'value' => $cfv->getValue(),
|
||||
'customField' => [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeOverrides(object $link): ?array
|
||||
{
|
||||
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;
|
||||
$reference = method_exists($link, 'getReferenceOverride') ? $link->getReferenceOverride() : null;
|
||||
$prix = method_exists($link, 'getPrixOverride') ? $link->getPrixOverride() : null;
|
||||
|
||||
if (null === $name && null === $reference && null === $prix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'reference' => $reference,
|
||||
'prix' => $prix,
|
||||
];
|
||||
}
|
||||
|
||||
private function indexById(array $links): array
|
||||
{
|
||||
$indexed = [];
|
||||
foreach ($links as $link) {
|
||||
if (is_array($link) && isset($link['id'])) {
|
||||
$indexed[$link['id']] = $link;
|
||||
}
|
||||
}
|
||||
|
||||
return $indexed;
|
||||
}
|
||||
}
|
||||
51
src/Mcp/Tool/Machine/RemoveMachineLinkTool.php
Normal file
51
src/Mcp/Tool/Machine/RemoveMachineLinkTool.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use App\Repository\MachineProductLinkRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'remove_machine_link',
|
||||
description: 'Remove a machine link by id and type (composant, piece, or product). Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class RemoveMachineLinkTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly MachineComponentLinkRepository $componentLinks,
|
||||
private readonly MachinePieceLinkRepository $pieceLinks,
|
||||
private readonly MachineProductLinkRepository $productLinks,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $linkId, string $linkType): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$link = match ($linkType) {
|
||||
'composant' => $this->componentLinks->find($linkId),
|
||||
'piece' => $this->pieceLinks->find($linkId),
|
||||
'product' => $this->productLinks->find($linkId),
|
||||
default => $this->mcpError('Validation', "Unknown link type '{$linkType}'. Expected composant, piece, or product."),
|
||||
};
|
||||
|
||||
if (null === $link) {
|
||||
$this->mcpError('NotFound', "Link {$linkId} of type {$linkType} not found.");
|
||||
}
|
||||
|
||||
$this->em->remove($link);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $linkId, 'type' => $linkType]);
|
||||
}
|
||||
}
|
||||
101
src/Mcp/Tool/Machine/UpdateMachineLinkTool.php
Normal file
101
src/Mcp/Tool/Machine/UpdateMachineLinkTool.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Entity\MachineComponentLink;
|
||||
use App\Entity\MachinePieceLink;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'update_machine_link',
|
||||
description: 'Update overrides (nameOverride, referenceOverride, prixOverride) or quantity on an existing machine link. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class UpdateMachineLinkTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly MachineComponentLinkRepository $componentLinks,
|
||||
private readonly MachinePieceLinkRepository $pieceLinks,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $linkId,
|
||||
string $linkType,
|
||||
?string $nameOverride = null,
|
||||
?string $referenceOverride = null,
|
||||
?string $prixOverride = null,
|
||||
?int $quantity = null,
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
switch ($linkType) {
|
||||
case 'composant':
|
||||
$link = $this->componentLinks->find($linkId);
|
||||
if (null === $link) {
|
||||
$this->mcpError('NotFound', "MachineComponentLink {$linkId} not found.");
|
||||
}
|
||||
|
||||
$this->applyOverrides($link, $nameOverride, $referenceOverride, $prixOverride);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $link->getId(),
|
||||
'type' => 'composant',
|
||||
'nameOverride' => $link->getNameOverride(),
|
||||
'referenceOverride' => $link->getReferenceOverride(),
|
||||
'prixOverride' => $link->getPrixOverride(),
|
||||
]);
|
||||
|
||||
case 'piece':
|
||||
$link = $this->pieceLinks->find($linkId);
|
||||
if (null === $link) {
|
||||
$this->mcpError('NotFound', "MachinePieceLink {$linkId} not found.");
|
||||
}
|
||||
|
||||
$this->applyOverrides($link, $nameOverride, $referenceOverride, $prixOverride);
|
||||
if (null !== $quantity) {
|
||||
$link->setQuantity($quantity);
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $link->getId(),
|
||||
'type' => 'piece',
|
||||
'nameOverride' => $link->getNameOverride(),
|
||||
'referenceOverride' => $link->getReferenceOverride(),
|
||||
'prixOverride' => $link->getPrixOverride(),
|
||||
'quantity' => $link->getQuantity(),
|
||||
]);
|
||||
|
||||
case 'product':
|
||||
$this->mcpError('Validation', 'Product links do not have updatable overrides.');
|
||||
|
||||
// no break
|
||||
default:
|
||||
$this->mcpError('Validation', "Unknown link type '{$linkType}'. Expected composant, piece, or product.");
|
||||
}
|
||||
}
|
||||
|
||||
private function applyOverrides(MachineComponentLink|MachinePieceLink $link, ?string $nameOverride, ?string $referenceOverride, ?string $prixOverride): void
|
||||
{
|
||||
if (null !== $nameOverride) {
|
||||
$link->setNameOverride($nameOverride);
|
||||
}
|
||||
if (null !== $referenceOverride) {
|
||||
$link->setReferenceOverride($referenceOverride);
|
||||
}
|
||||
if (null !== $prixOverride) {
|
||||
$link->setPrixOverride($prixOverride);
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/Mcp/Tool/Machine/UpdateMachineTool.php
Normal file
85
src/Mcp/Tool/Machine/UpdateMachineTool.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Machine;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\SiteRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'update_machine',
|
||||
description: 'Update an existing machine. Only provided fields are changed. prix must be a string. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class UpdateMachineTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly MachineRepository $machines,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly SiteRepository $sites,
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param null|string[] $constructeurIds
|
||||
*/
|
||||
public function __invoke(
|
||||
string $machineId,
|
||||
?string $name = null,
|
||||
?string $reference = null,
|
||||
?string $prix = null,
|
||||
?string $siteId = null,
|
||||
?array $constructeurIds = null,
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$machine = $this->machines->find($machineId);
|
||||
|
||||
if (!$machine) {
|
||||
$this->mcpError('not_found', "Machine not found: {$machineId}");
|
||||
}
|
||||
|
||||
if (null !== $name) {
|
||||
$machine->setName($name);
|
||||
}
|
||||
if (null !== $reference) {
|
||||
$machine->setReference($reference);
|
||||
}
|
||||
if (null !== $prix) {
|
||||
$machine->setPrix($prix);
|
||||
}
|
||||
|
||||
if (null !== $siteId) {
|
||||
$site = $this->sites->find($siteId);
|
||||
if (!$site) {
|
||||
$this->mcpError('not_found', "Site not found: {$siteId}");
|
||||
}
|
||||
$machine->setSite($site);
|
||||
}
|
||||
|
||||
if (null !== $constructeurIds) {
|
||||
foreach ($machine->getConstructeurs()->toArray() as $existing) {
|
||||
$machine->removeConstructeur($existing);
|
||||
}
|
||||
foreach ($constructeurIds as $cId) {
|
||||
$c = $this->constructeurs->find($cId);
|
||||
if (!$c) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$cId}");
|
||||
}
|
||||
$machine->addConstructeur($c);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['id' => $machine->getId(), 'name' => $machine->getName()]);
|
||||
}
|
||||
}
|
||||
58
src/Mcp/Tool/McpToolHelper.php
Normal file
58
src/Mcp/Tool/McpToolHelper.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool;
|
||||
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use RuntimeException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
trait McpToolHelper
|
||||
{
|
||||
private function requireRole(Security $security, string $role): void
|
||||
{
|
||||
if (!$security->isGranted($role)) {
|
||||
throw new RuntimeException("Permission denied: {$role} required.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{TextContent}
|
||||
*/
|
||||
private function jsonResponse(array $data): array
|
||||
{
|
||||
return [new TextContent(text: json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE))];
|
||||
}
|
||||
|
||||
private function mcpError(string $category, string $message): never
|
||||
{
|
||||
throw new RuntimeException("{$category}: {$message}");
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{page: int, limit: int, offset: int}
|
||||
*/
|
||||
private function paginationParams(int $page = 1, int $limit = 30): array
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$limit = min(100, max(1, $limit));
|
||||
$offset = ($page - 1) * $limit;
|
||||
|
||||
return ['page' => $page, 'limit' => $limit, 'offset' => $offset];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{TextContent}
|
||||
*/
|
||||
private function paginatedResponse(array $items, int $total, int $page, int $limit): array
|
||||
{
|
||||
return $this->jsonResponse([
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'limit' => $limit,
|
||||
'pageCount' => (int) ceil($total / max(1, $limit)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
57
src/Mcp/Tool/ModelType/CreateModelTypeTool.php
Normal file
57
src/Mcp/Tool/ModelType/CreateModelTypeTool.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\ModelType;
|
||||
|
||||
use App\Entity\ModelType;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'create_model_type',
|
||||
description: 'Create a new model type. Category must be one of: composant, piece, product. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class CreateModelTypeTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $name, string $category, string $code = ''): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$enumCategory = match (strtolower($category)) {
|
||||
'composant', 'component' => ModelCategory::COMPONENT,
|
||||
'piece' => ModelCategory::PIECE,
|
||||
'product' => ModelCategory::PRODUCT,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (null === $enumCategory) {
|
||||
$this->mcpError('validation', "Invalid category '{$category}'. Must be one of: composant, piece, product.");
|
||||
}
|
||||
|
||||
$mt = new ModelType();
|
||||
$mt->setName($name);
|
||||
$mt->setCategory($enumCategory);
|
||||
$mt->setCode('' !== $code ? $code : strtoupper(substr(str_replace(' ', '-', $name), 0, 20)).'-'.bin2hex(random_bytes(3)));
|
||||
|
||||
$this->em->persist($mt);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $mt->getId(),
|
||||
'name' => $mt->getName(),
|
||||
'code' => $mt->getCode(),
|
||||
'category' => $mt->getCategory()->value,
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
src/Mcp/Tool/ModelType/DeleteModelTypeTool.php
Normal file
42
src/Mcp/Tool/ModelType/DeleteModelTypeTool.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\ModelType;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'delete_model_type',
|
||||
description: 'Delete a model type by ID. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class DeleteModelTypeTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $modelTypeId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$mt = $this->modelTypes->find($modelTypeId);
|
||||
|
||||
if (!$mt) {
|
||||
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
|
||||
}
|
||||
|
||||
$this->em->remove($mt);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $modelTypeId]);
|
||||
}
|
||||
}
|
||||
78
src/Mcp/Tool/ModelType/GetModelTypeTool.php
Normal file
78
src/Mcp/Tool/ModelType/GetModelTypeTool.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\ModelType;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_model_type',
|
||||
description: 'Get a single model type by ID with full details including skeleton requirements.',
|
||||
)]
|
||||
class GetModelTypeTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $modelTypeId): array
|
||||
{
|
||||
$mt = $this->modelTypes->find($modelTypeId);
|
||||
|
||||
if (!$mt) {
|
||||
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
|
||||
}
|
||||
|
||||
$skeletonPieces = [];
|
||||
foreach ($mt->getSkeletonPieceRequirements() as $req) {
|
||||
$skeletonPieces[] = [
|
||||
'id' => $req->getId(),
|
||||
'typePieceId' => $req->getTypePiece()->getId(),
|
||||
'typePiece' => $req->getTypePiece()->getName(),
|
||||
'position' => $req->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$skeletonProducts = [];
|
||||
foreach ($mt->getSkeletonProductRequirements() as $req) {
|
||||
$skeletonProducts[] = [
|
||||
'id' => $req->getId(),
|
||||
'typeProductId' => $req->getTypeProduct()->getId(),
|
||||
'typeProduct' => $req->getTypeProduct()->getName(),
|
||||
'familyCode' => $req->getFamilyCode(),
|
||||
'position' => $req->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
$skeletonSubcomponents = [];
|
||||
foreach ($mt->getSkeletonSubcomponentRequirements() as $req) {
|
||||
$skeletonSubcomponents[] = [
|
||||
'id' => $req->getId(),
|
||||
'alias' => $req->getAlias(),
|
||||
'familyCode' => $req->getFamilyCode(),
|
||||
'typeComposantId' => $req->getTypeComposant()?->getId(),
|
||||
'typeComposant' => $req->getTypeComposant()?->getName(),
|
||||
'position' => $req->getPosition(),
|
||||
];
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $mt->getId(),
|
||||
'name' => $mt->getName(),
|
||||
'code' => $mt->getCode(),
|
||||
'category' => $mt->getCategory()->value,
|
||||
'notes' => $mt->getNotes(),
|
||||
'description' => $mt->getDescription(),
|
||||
'skeletonPieceRequirements' => $skeletonPieces,
|
||||
'skeletonProductRequirements' => $skeletonProducts,
|
||||
'skeletonSubcomponentRequirements' => $skeletonSubcomponents,
|
||||
'createdAt' => $mt->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'updatedAt' => $mt->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
79
src/Mcp/Tool/ModelType/ListModelTypesTool.php
Normal file
79
src/Mcp/Tool/ModelType/ListModelTypesTool.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?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;
|
||||
|
||||
#[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 = ''): array
|
||||
{
|
||||
$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,
|
||||
};
|
||||
}
|
||||
}
|
||||
67
src/Mcp/Tool/ModelType/SyncModelTypeTool.php
Normal file
67
src/Mcp/Tool/ModelType/SyncModelTypeTool.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?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 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,
|
||||
): array {
|
||||
$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());
|
||||
}
|
||||
}
|
||||
53
src/Mcp/Tool/ModelType/UpdateModelTypeTool.php
Normal file
53
src/Mcp/Tool/ModelType/UpdateModelTypeTool.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\ModelType;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: '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): array
|
||||
{
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
80
src/Mcp/Tool/Piece/CreatePieceTool.php
Normal file
80
src/Mcp/Tool/Piece/CreatePieceTool.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?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 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 = [],
|
||||
): array {
|
||||
$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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
src/Mcp/Tool/Piece/DeletePieceTool.php
Normal file
42
src/Mcp/Tool/Piece/DeletePieceTool.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?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 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): array
|
||||
{
|
||||
$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]);
|
||||
}
|
||||
}
|
||||
59
src/Mcp/Tool/Piece/GetPieceTool.php
Normal file
59
src/Mcp/Tool/Piece/GetPieceTool.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Piece;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\PieceRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[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): array
|
||||
{
|
||||
$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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
src/Mcp/Tool/Piece/ListPiecesTool.php
Normal file
55
src/Mcp/Tool/Piece/ListPiecesTool.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Piece;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\PieceRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[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 = ''): array
|
||||
{
|
||||
$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']);
|
||||
}
|
||||
}
|
||||
93
src/Mcp/Tool/Piece/UpdatePieceTool.php
Normal file
93
src/Mcp/Tool/Piece/UpdatePieceTool.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?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 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,
|
||||
): array {
|
||||
$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()]);
|
||||
}
|
||||
}
|
||||
76
src/Mcp/Tool/Product/CreateProductTool.php
Normal file
76
src/Mcp/Tool/Product/CreateProductTool.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?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 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 = [],
|
||||
): array {
|
||||
$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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
src/Mcp/Tool/Product/DeleteProductTool.php
Normal file
42
src/Mcp/Tool/Product/DeleteProductTool.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?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 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): array
|
||||
{
|
||||
$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]);
|
||||
}
|
||||
}
|
||||
58
src/Mcp/Tool/Product/GetProductTool.php
Normal file
58
src/Mcp/Tool/Product/GetProductTool.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Product;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ProductRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[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): array
|
||||
{
|
||||
$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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
src/Mcp/Tool/Product/ListProductsTool.php
Normal file
55
src/Mcp/Tool/Product/ListProductsTool.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Product;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ProductRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[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 = ''): array
|
||||
{
|
||||
$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']);
|
||||
}
|
||||
}
|
||||
89
src/Mcp/Tool/Product/UpdateProductTool.php
Normal file
89
src/Mcp/Tool/Product/UpdateProductTool.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Product;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'update_product',
|
||||
description: 'Update an existing product. Only provided fields are changed. supplierPrice must be a string. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class UpdateProductTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly ProductRepository $products,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
private readonly ConstructeurRepository $constructeurs,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param null|string[] $constructeurIds
|
||||
*/
|
||||
public function __invoke(
|
||||
string $productId,
|
||||
?string $name = null,
|
||||
?string $reference = null,
|
||||
?string $supplierPrice = null,
|
||||
?string $modelTypeId = null,
|
||||
?array $constructeurIds = null,
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$product = $this->products->find($productId);
|
||||
|
||||
if (!$product) {
|
||||
$this->mcpError('not_found', "Product not found: {$productId}");
|
||||
}
|
||||
|
||||
if (null !== $name) {
|
||||
$product->setName($name);
|
||||
}
|
||||
if (null !== $reference) {
|
||||
$product->setReference($reference);
|
||||
}
|
||||
if (null !== $supplierPrice) {
|
||||
$product->setSupplierPrice($supplierPrice);
|
||||
}
|
||||
|
||||
if (null !== $modelTypeId) {
|
||||
if ('' === $modelTypeId) {
|
||||
$product->setTypeProduct(null);
|
||||
} else {
|
||||
$modelType = $this->modelTypes->find($modelTypeId);
|
||||
if (!$modelType) {
|
||||
$this->mcpError('not_found', "ModelType not found: {$modelTypeId}");
|
||||
}
|
||||
$product->setTypeProduct($modelType);
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== $constructeurIds) {
|
||||
foreach ($product->getConstructeurs()->toArray() as $existing) {
|
||||
$product->removeConstructeur($existing);
|
||||
}
|
||||
foreach ($constructeurIds as $cId) {
|
||||
$c = $this->constructeurs->find($cId);
|
||||
if (!$c) {
|
||||
$this->mcpError('not_found', "Constructeur not found: {$cId}");
|
||||
}
|
||||
$product->addConstructeur($c);
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['id' => $product->getId(), 'name' => $product->getName()]);
|
||||
}
|
||||
}
|
||||
113
src/Mcp/Tool/SearchInventoryTool.php
Normal file
113
src/Mcp/Tool/SearchInventoryTool.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool;
|
||||
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\ConstructeurRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Repository\SiteRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'search_inventory',
|
||||
description: 'Global search across all inventory entities (machines, pieces, composants, products, sites, constructeurs). Searches by name and reference (when available). Returns a flat list of matching results.',
|
||||
)]
|
||||
class SearchInventoryTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
private const ALLOWED_TYPES = ['machine', 'piece', 'composant', 'product', 'site', 'constructeur'];
|
||||
|
||||
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 ConstructeurRepository $constructeurs,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $query, string $types = '', int $limit = 20): array
|
||||
{
|
||||
$query = trim($query);
|
||||
if ('' === $query) {
|
||||
return $this->jsonResponse([]);
|
||||
}
|
||||
|
||||
$limit = min(100, max(1, $limit));
|
||||
$searchTypes = $this->resolveTypes($types);
|
||||
$results = [];
|
||||
|
||||
foreach ($searchTypes as $type) {
|
||||
$results = array_merge($results, match ($type) {
|
||||
'machine' => $this->searchWithReference($this->machines, 'm', 'machine', $query),
|
||||
'piece' => $this->searchWithReference($this->pieces, 'p', 'piece', $query),
|
||||
'composant' => $this->searchWithReference($this->composants, 'c', 'composant', $query),
|
||||
'product' => $this->searchWithReference($this->products, 'p', 'product', $query),
|
||||
'site' => $this->searchNameOnly($this->sites, 's', 'site', $query),
|
||||
'constructeur' => $this->searchNameOnly($this->constructeurs, 'c', 'constructeur', $query),
|
||||
});
|
||||
}
|
||||
|
||||
$results = array_slice($results, 0, $limit);
|
||||
|
||||
return $this->jsonResponse($results);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function resolveTypes(string $types): array
|
||||
{
|
||||
if ('' === trim($types)) {
|
||||
return self::ALLOWED_TYPES;
|
||||
}
|
||||
|
||||
$requested = array_map('trim', explode(',', strtolower($types)));
|
||||
|
||||
return array_values(array_intersect($requested, self::ALLOWED_TYPES));
|
||||
}
|
||||
|
||||
private function searchWithReference(object $repository, string $alias, string $type, string $search): array
|
||||
{
|
||||
$qb = $repository->createQueryBuilder($alias)
|
||||
->select("{$alias}.id", "{$alias}.name", "{$alias}.reference")
|
||||
->where("LOWER({$alias}.name) LIKE LOWER(:search)")
|
||||
->orWhere("LOWER({$alias}.reference) LIKE LOWER(:search)")
|
||||
->setParameter('search', "%{$search}%")
|
||||
->orderBy("{$alias}.name", 'ASC')
|
||||
;
|
||||
|
||||
$rows = $qb->getQuery()->getArrayResult();
|
||||
|
||||
return array_map(fn (array $row) => [
|
||||
'type' => $type,
|
||||
'id' => $row['id'],
|
||||
'name' => $row['name'],
|
||||
'reference' => $row['reference'] ?? null,
|
||||
], $rows);
|
||||
}
|
||||
|
||||
private function searchNameOnly(object $repository, string $alias, string $type, string $search): array
|
||||
{
|
||||
$qb = $repository->createQueryBuilder($alias)
|
||||
->select("{$alias}.id", "{$alias}.name")
|
||||
->where("LOWER({$alias}.name) LIKE LOWER(:search)")
|
||||
->setParameter('search', "%{$search}%")
|
||||
->orderBy("{$alias}.name", 'ASC')
|
||||
;
|
||||
|
||||
$rows = $qb->getQuery()->getArrayResult();
|
||||
|
||||
return array_map(fn (array $row) => [
|
||||
'type' => $type,
|
||||
'id' => $row['id'],
|
||||
'name' => $row['name'],
|
||||
'reference' => null,
|
||||
], $rows);
|
||||
}
|
||||
}
|
||||
54
src/Mcp/Tool/Site/CreateSiteTool.php
Normal file
54
src/Mcp/Tool/Site/CreateSiteTool.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Site;
|
||||
|
||||
use App\Entity\Site;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'create_site',
|
||||
description: 'Create a new industrial site. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class CreateSiteTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $name,
|
||||
string $contactName = '',
|
||||
string $contactPhone = '',
|
||||
string $contactAddress = '',
|
||||
string $contactPostalCode = '',
|
||||
string $contactCity = '',
|
||||
string $color = '',
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$site = new Site();
|
||||
$site->setName($name);
|
||||
$site->setContactName($contactName);
|
||||
$site->setContactPhone($contactPhone);
|
||||
$site->setContactAddress($contactAddress);
|
||||
$site->setContactPostalCode($contactPostalCode);
|
||||
$site->setContactCity($contactCity);
|
||||
$site->setColor($color);
|
||||
|
||||
$this->em->persist($site);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $site->getId(),
|
||||
'name' => $site->getName(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
src/Mcp/Tool/Site/DeleteSiteTool.php
Normal file
42
src/Mcp/Tool/Site/DeleteSiteTool.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Site;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\SiteRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'delete_site',
|
||||
description: 'Delete a site by ID. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class DeleteSiteTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly SiteRepository $sites,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $siteId): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$site = $this->sites->find($siteId);
|
||||
|
||||
if (!$site) {
|
||||
$this->mcpError('not_found', "Site not found: {$siteId}");
|
||||
}
|
||||
|
||||
$this->em->remove($site);
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['deleted' => true, 'id' => $siteId]);
|
||||
}
|
||||
}
|
||||
44
src/Mcp/Tool/Site/GetSiteTool.php
Normal file
44
src/Mcp/Tool/Site/GetSiteTool.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Site;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\SiteRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'get_site',
|
||||
description: 'Get a single site by ID with all its details (name, contact info, address).',
|
||||
)]
|
||||
class GetSiteTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly SiteRepository $sites,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $siteId): array
|
||||
{
|
||||
$site = $this->sites->find($siteId);
|
||||
|
||||
if (!$site) {
|
||||
$this->mcpError('not_found', "Site not found: {$siteId}");
|
||||
}
|
||||
|
||||
return $this->jsonResponse([
|
||||
'id' => $site->getId(),
|
||||
'name' => $site->getName(),
|
||||
'contactName' => $site->getContactName(),
|
||||
'contactPhone' => $site->getContactPhone(),
|
||||
'contactAddress' => $site->getContactAddress(),
|
||||
'contactPostalCode' => $site->getContactPostalCode(),
|
||||
'contactCity' => $site->getContactCity(),
|
||||
'color' => $site->getColor(),
|
||||
'createdAt' => $site->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'updatedAt' => $site->getUpdatedAt()->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
src/Mcp/Tool/Site/ListSitesTool.php
Normal file
55
src/Mcp/Tool/Site/ListSitesTool.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Site;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\SiteRepository;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_sites',
|
||||
description: 'List all industrial sites with pagination. Sites contain machines. Filterable by name.',
|
||||
)]
|
||||
class ListSitesTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly SiteRepository $sites,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $page = 1, int $limit = 30, string $search = ''): array
|
||||
{
|
||||
$p = $this->paginationParams($page, $limit);
|
||||
|
||||
$countQb = $this->sites->createQueryBuilder('s')
|
||||
->select('COUNT(s.id)')
|
||||
;
|
||||
|
||||
$qb = $this->sites->createQueryBuilder('s')
|
||||
->select('s.id', 's.name', 's.contactName', 's.contactCity', 's.contactPhone')
|
||||
->orderBy('s.name', 'ASC')
|
||||
;
|
||||
|
||||
if ('' !== $search) {
|
||||
$countQb->andWhere('LOWER(s.name) LIKE LOWER(:search)')
|
||||
->setParameter('search', "%{$search}%")
|
||||
;
|
||||
$qb->andWhere('LOWER(s.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']);
|
||||
}
|
||||
}
|
||||
71
src/Mcp/Tool/Site/UpdateSiteTool.php
Normal file
71
src/Mcp/Tool/Site/UpdateSiteTool.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Site;
|
||||
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use App\Repository\SiteRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'update_site',
|
||||
description: 'Update an existing site. Only provided fields are changed. Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class UpdateSiteTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly SiteRepository $sites,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
string $siteId,
|
||||
?string $name = null,
|
||||
?string $contactName = null,
|
||||
?string $contactPhone = null,
|
||||
?string $contactAddress = null,
|
||||
?string $contactPostalCode = null,
|
||||
?string $contactCity = null,
|
||||
?string $color = null,
|
||||
): array {
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$site = $this->sites->find($siteId);
|
||||
|
||||
if (!$site) {
|
||||
$this->mcpError('not_found', "Site not found: {$siteId}");
|
||||
}
|
||||
|
||||
if (null !== $name) {
|
||||
$site->setName($name);
|
||||
}
|
||||
if (null !== $contactName) {
|
||||
$site->setContactName($contactName);
|
||||
}
|
||||
if (null !== $contactPhone) {
|
||||
$site->setContactPhone($contactPhone);
|
||||
}
|
||||
if (null !== $contactAddress) {
|
||||
$site->setContactAddress($contactAddress);
|
||||
}
|
||||
if (null !== $contactPostalCode) {
|
||||
$site->setContactPostalCode($contactPostalCode);
|
||||
}
|
||||
if (null !== $contactCity) {
|
||||
$site->setContactCity($contactCity);
|
||||
}
|
||||
if (null !== $color) {
|
||||
$site->setColor($color);
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse(['id' => $site->getId(), 'name' => $site->getName()]);
|
||||
}
|
||||
}
|
||||
139
src/Mcp/Tool/Slot/ListSlotsTool.php
Normal file
139
src/Mcp/Tool/Slot/ListSlotsTool.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Slot;
|
||||
|
||||
use App\Entity\ComposantPieceSlot;
|
||||
use App\Entity\ComposantProductSlot;
|
||||
use App\Entity\ComposantSubcomponentSlot;
|
||||
use App\Entity\PieceProductSlot;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
|
||||
#[McpTool(
|
||||
name: 'list_slots',
|
||||
description: 'List all slots for a composant or piece. Composants have piece/product/subcomponent slots. Pieces have product slots only.',
|
||||
)]
|
||||
class ListSlotsTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function __invoke(string $entityType, string $entityId): array
|
||||
{
|
||||
if ('composant' === $entityType) {
|
||||
return $this->listComposantSlots($entityId);
|
||||
}
|
||||
|
||||
if ('piece' === $entityType) {
|
||||
return $this->listPieceSlots($entityId);
|
||||
}
|
||||
|
||||
$this->mcpError('validation', "entityType must be 'composant' or 'piece', got '{$entityType}'.");
|
||||
}
|
||||
|
||||
private function listComposantSlots(string $composantId): array
|
||||
{
|
||||
$pieceSlots = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'ps.id',
|
||||
"'piece' AS slotType",
|
||||
'ps.position',
|
||||
'ps.quantity',
|
||||
'tp.name AS typeName',
|
||||
'sp.id AS selectedEntityId',
|
||||
'sp.name AS selectedEntityName',
|
||||
)
|
||||
->from(ComposantPieceSlot::class, 'ps')
|
||||
->leftJoin('ps.typePiece', 'tp')
|
||||
->leftJoin('ps.selectedPiece', 'sp')
|
||||
->where('IDENTITY(ps.composant) = :cid')
|
||||
->setParameter('cid', $composantId)
|
||||
->orderBy('ps.position', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
$productSlots = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'prs.id',
|
||||
"'product' AS slotType",
|
||||
'prs.position',
|
||||
'tp.name AS typeName',
|
||||
'sp.id AS selectedEntityId',
|
||||
'sp.name AS selectedEntityName',
|
||||
)
|
||||
->from(ComposantProductSlot::class, 'prs')
|
||||
->leftJoin('prs.typeProduct', 'tp')
|
||||
->leftJoin('prs.selectedProduct', 'sp')
|
||||
->where('IDENTITY(prs.composant) = :cid')
|
||||
->setParameter('cid', $composantId)
|
||||
->orderBy('prs.position', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
$subSlots = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'ss.id',
|
||||
"'subcomponent' AS slotType",
|
||||
'ss.position',
|
||||
'ss.alias',
|
||||
'tc.name AS typeName',
|
||||
'sc.id AS selectedEntityId',
|
||||
'sc.name AS selectedEntityName',
|
||||
)
|
||||
->from(ComposantSubcomponentSlot::class, 'ss')
|
||||
->leftJoin('ss.typeComposant', 'tc')
|
||||
->leftJoin('ss.selectedComposant', 'sc')
|
||||
->where('IDENTITY(ss.composant) = :cid')
|
||||
->setParameter('cid', $composantId)
|
||||
->orderBy('ss.position', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
$slots = array_merge($pieceSlots, $productSlots, $subSlots);
|
||||
|
||||
return $this->jsonResponse([
|
||||
'entityType' => 'composant',
|
||||
'entityId' => $composantId,
|
||||
'slots' => $slots,
|
||||
'total' => count($slots),
|
||||
]);
|
||||
}
|
||||
|
||||
private function listPieceSlots(string $pieceId): array
|
||||
{
|
||||
$slots = $this->em->createQueryBuilder()
|
||||
->select(
|
||||
'pps.id',
|
||||
"'product' AS slotType",
|
||||
'pps.position',
|
||||
'tp.name AS typeName',
|
||||
'sp.id AS selectedEntityId',
|
||||
'sp.name AS selectedEntityName',
|
||||
)
|
||||
->from(PieceProductSlot::class, 'pps')
|
||||
->leftJoin('pps.typeProduct', 'tp')
|
||||
->leftJoin('pps.selectedProduct', 'sp')
|
||||
->where('IDENTITY(pps.piece) = :pid')
|
||||
->setParameter('pid', $pieceId)
|
||||
->orderBy('pps.position', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return $this->jsonResponse([
|
||||
'entityType' => 'piece',
|
||||
'entityId' => $pieceId,
|
||||
'slots' => $slots,
|
||||
'total' => count($slots),
|
||||
]);
|
||||
}
|
||||
}
|
||||
178
src/Mcp/Tool/Slot/UpdateSlotsTool.php
Normal file
178
src/Mcp/Tool/Slot/UpdateSlotsTool.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tool\Slot;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\ComposantPieceSlot;
|
||||
use App\Entity\ComposantProductSlot;
|
||||
use App\Entity\ComposantSubcomponentSlot;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\PieceProductSlot;
|
||||
use App\Entity\Product;
|
||||
use App\Mcp\Tool\McpToolHelper;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
#[McpTool(
|
||||
name: 'update_slots',
|
||||
description: 'Update selected entities on one or more slots. Each slot entry needs slotId, slotType, and the selected entity ID (or null to clear). Requires ROLE_GESTIONNAIRE.',
|
||||
)]
|
||||
class UpdateSlotsTool
|
||||
{
|
||||
use McpToolHelper;
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(array $slots): array
|
||||
{
|
||||
$this->requireRole($this->security, 'ROLE_GESTIONNAIRE');
|
||||
|
||||
$results = [];
|
||||
|
||||
foreach ($slots as $entry) {
|
||||
$slotId = $entry['slotId'] ?? null;
|
||||
$slotType = $entry['slotType'] ?? null;
|
||||
|
||||
if (null === $slotId || null === $slotType) {
|
||||
$this->mcpError('validation', 'Each slot entry must have slotId and slotType.');
|
||||
}
|
||||
|
||||
$results[] = match ($slotType) {
|
||||
'composant_piece' => $this->updateComposantPieceSlot($slotId, $entry),
|
||||
'composant_product' => $this->updateComposantProductSlot($slotId, $entry),
|
||||
'composant_subcomponent' => $this->updateComposantSubcomponentSlot($slotId, $entry),
|
||||
'piece_product' => $this->updatePieceProductSlot($slotId, $entry),
|
||||
default => $this->mcpError('validation', "Unknown slotType '{$slotType}'."),
|
||||
};
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $this->jsonResponse([
|
||||
'updated' => $results,
|
||||
'total' => count($results),
|
||||
]);
|
||||
}
|
||||
|
||||
private function updateComposantPieceSlot(string $slotId, array $entry): array
|
||||
{
|
||||
$slot = $this->em->getRepository(ComposantPieceSlot::class)->find($slotId);
|
||||
|
||||
if (null === $slot) {
|
||||
$this->mcpError('not_found', "ComposantPieceSlot '{$slotId}' not found.");
|
||||
}
|
||||
|
||||
$selectedPieceId = $entry['selectedPieceId'] ?? null;
|
||||
$selectedPiece = null;
|
||||
|
||||
if (null !== $selectedPieceId) {
|
||||
$selectedPiece = $this->em->getRepository(Piece::class)->find($selectedPieceId);
|
||||
|
||||
if (null === $selectedPiece) {
|
||||
$this->mcpError('not_found', "Piece '{$selectedPieceId}' not found.");
|
||||
}
|
||||
}
|
||||
|
||||
$slot->setSelectedPiece($selectedPiece);
|
||||
|
||||
return [
|
||||
'slotId' => $slot->getId(),
|
||||
'slotType' => 'composant_piece',
|
||||
'selectedEntityId' => $selectedPiece?->getId(),
|
||||
'selectedEntityName' => $selectedPiece?->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
private function updateComposantProductSlot(string $slotId, array $entry): array
|
||||
{
|
||||
$slot = $this->em->getRepository(ComposantProductSlot::class)->find($slotId);
|
||||
|
||||
if (null === $slot) {
|
||||
$this->mcpError('not_found', "ComposantProductSlot '{$slotId}' not found.");
|
||||
}
|
||||
|
||||
$selectedProductId = $entry['selectedProductId'] ?? null;
|
||||
$selectedProduct = null;
|
||||
|
||||
if (null !== $selectedProductId) {
|
||||
$selectedProduct = $this->em->getRepository(Product::class)->find($selectedProductId);
|
||||
|
||||
if (null === $selectedProduct) {
|
||||
$this->mcpError('not_found', "Product '{$selectedProductId}' not found.");
|
||||
}
|
||||
}
|
||||
|
||||
$slot->setSelectedProduct($selectedProduct);
|
||||
|
||||
return [
|
||||
'slotId' => $slot->getId(),
|
||||
'slotType' => 'composant_product',
|
||||
'selectedEntityId' => $selectedProduct?->getId(),
|
||||
'selectedEntityName' => $selectedProduct?->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
private function updateComposantSubcomponentSlot(string $slotId, array $entry): array
|
||||
{
|
||||
$slot = $this->em->getRepository(ComposantSubcomponentSlot::class)->find($slotId);
|
||||
|
||||
if (null === $slot) {
|
||||
$this->mcpError('not_found', "ComposantSubcomponentSlot '{$slotId}' not found.");
|
||||
}
|
||||
|
||||
$selectedComposantId = $entry['selectedComposantId'] ?? null;
|
||||
$selectedComposant = null;
|
||||
|
||||
if (null !== $selectedComposantId) {
|
||||
$selectedComposant = $this->em->getRepository(Composant::class)->find($selectedComposantId);
|
||||
|
||||
if (null === $selectedComposant) {
|
||||
$this->mcpError('not_found', "Composant '{$selectedComposantId}' not found.");
|
||||
}
|
||||
}
|
||||
|
||||
$slot->setSelectedComposant($selectedComposant);
|
||||
|
||||
return [
|
||||
'slotId' => $slot->getId(),
|
||||
'slotType' => 'composant_subcomponent',
|
||||
'selectedEntityId' => $selectedComposant?->getId(),
|
||||
'selectedEntityName' => $selectedComposant?->getName(),
|
||||
];
|
||||
}
|
||||
|
||||
private function updatePieceProductSlot(string $slotId, array $entry): array
|
||||
{
|
||||
$slot = $this->em->getRepository(PieceProductSlot::class)->find($slotId);
|
||||
|
||||
if (null === $slot) {
|
||||
$this->mcpError('not_found', "PieceProductSlot '{$slotId}' not found.");
|
||||
}
|
||||
|
||||
$selectedProductId = $entry['selectedProductId'] ?? null;
|
||||
$selectedProduct = null;
|
||||
|
||||
if (null !== $selectedProductId) {
|
||||
$selectedProduct = $this->em->getRepository(Product::class)->find($selectedProductId);
|
||||
|
||||
if (null === $selectedProduct) {
|
||||
$this->mcpError('not_found', "Product '{$selectedProductId}' not found.");
|
||||
}
|
||||
}
|
||||
|
||||
$slot->setSelectedProduct($selectedProduct);
|
||||
|
||||
return [
|
||||
'slotId' => $slot->getId(),
|
||||
'slotType' => 'piece_product',
|
||||
'selectedEntityId' => $selectedProduct?->getId(),
|
||||
'selectedEntityName' => $selectedProduct?->getName(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -18,55 +18,158 @@ class SkeletonStructureService
|
||||
|
||||
public function updateSkeletonRequirements(ModelType $modelType, array $structure): void
|
||||
{
|
||||
// Clear existing requirements
|
||||
foreach ($modelType->getSkeletonPieceRequirements() as $req) {
|
||||
$modelType->removeSkeletonPieceRequirement($req);
|
||||
}
|
||||
// Update piece requirements in-place (match by typeId, then update position)
|
||||
$this->syncPieceRequirements($modelType, $structure['pieces'] ?? []);
|
||||
|
||||
foreach ($modelType->getSkeletonProductRequirements() as $req) {
|
||||
$modelType->removeSkeletonProductRequirement($req);
|
||||
}
|
||||
// Update product requirements in-place
|
||||
$this->syncProductRequirements($modelType, $structure['products'] ?? []);
|
||||
|
||||
foreach ($modelType->getSkeletonSubcomponentRequirements() as $req) {
|
||||
$modelType->removeSkeletonSubcomponentRequirement($req);
|
||||
}
|
||||
|
||||
// Create piece requirements
|
||||
foreach (($structure['pieces'] ?? []) as $i => $pieceData) {
|
||||
$req = new SkeletonPieceRequirement();
|
||||
$req->setModelType($modelType);
|
||||
$req->setTypePiece($this->em->getReference(ModelType::class, $pieceData['typePieceId']));
|
||||
$req->setPosition($i);
|
||||
$modelType->addSkeletonPieceRequirement($req);
|
||||
}
|
||||
|
||||
// Create product requirements (shared by component + piece types)
|
||||
foreach (($structure['products'] ?? []) as $i => $prodData) {
|
||||
$req = new SkeletonProductRequirement();
|
||||
$req->setModelType($modelType);
|
||||
$req->setTypeProduct($this->em->getReference(ModelType::class, $prodData['typeProductId']));
|
||||
$req->setFamilyCode($prodData['familyCode'] ?? null);
|
||||
$req->setPosition($i);
|
||||
$modelType->addSkeletonProductRequirement($req);
|
||||
}
|
||||
|
||||
// Create subcomponent requirements (component types only)
|
||||
foreach (($structure['subcomponents'] ?? []) as $i => $subData) {
|
||||
$req = new SkeletonSubcomponentRequirement();
|
||||
$req->setModelType($modelType);
|
||||
$req->setAlias($subData['alias'] ?? '');
|
||||
$req->setFamilyCode($subData['familyCode'] ?? '');
|
||||
if (!empty($subData['typeComposantId'])) {
|
||||
$req->setTypeComposant($this->em->getReference(ModelType::class, $subData['typeComposantId']));
|
||||
}
|
||||
$req->setPosition($i);
|
||||
$modelType->addSkeletonSubcomponentRequirement($req);
|
||||
}
|
||||
// Update subcomponent requirements in-place
|
||||
$this->syncSubcomponentRequirements($modelType, $structure['subcomponents'] ?? []);
|
||||
|
||||
// Update custom field definitions
|
||||
$this->updateCustomFields($modelType, $structure['customFields'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{typePieceId: string}> $proposedPieces
|
||||
*/
|
||||
private function syncPieceRequirements(ModelType $modelType, array $proposedPieces): void
|
||||
{
|
||||
$existing = $modelType->getSkeletonPieceRequirements()->toArray();
|
||||
|
||||
// Index existing by typeId for matching
|
||||
$existingByTypeId = [];
|
||||
foreach ($existing as $req) {
|
||||
$existingByTypeId[$req->getTypePiece()->getId()][] = $req;
|
||||
}
|
||||
|
||||
$matched = [];
|
||||
$toCreate = [];
|
||||
|
||||
foreach ($proposedPieces as $i => $pieceData) {
|
||||
$typeId = $pieceData['typePieceId'];
|
||||
if (!empty($existingByTypeId[$typeId])) {
|
||||
// Reuse existing requirement, update position
|
||||
$req = array_shift($existingByTypeId[$typeId]);
|
||||
$req->setPosition($i);
|
||||
$matched[spl_object_id($req)] = true;
|
||||
} else {
|
||||
$toCreate[] = ['data' => $pieceData, 'position' => $i];
|
||||
}
|
||||
}
|
||||
|
||||
// Remove unmatched existing requirements
|
||||
foreach ($existing as $req) {
|
||||
if (!isset($matched[spl_object_id($req)])) {
|
||||
$modelType->removeSkeletonPieceRequirement($req);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new requirements
|
||||
foreach ($toCreate as $item) {
|
||||
$req = new SkeletonPieceRequirement();
|
||||
$req->setModelType($modelType);
|
||||
$req->setTypePiece($this->em->getReference(ModelType::class, $item['data']['typePieceId']));
|
||||
$req->setPosition($item['position']);
|
||||
$modelType->addSkeletonPieceRequirement($req);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{typeProductId: string, familyCode?: ?string}> $proposedProducts
|
||||
*/
|
||||
private function syncProductRequirements(ModelType $modelType, array $proposedProducts): void
|
||||
{
|
||||
$existing = $modelType->getSkeletonProductRequirements()->toArray();
|
||||
|
||||
$existingByTypeId = [];
|
||||
foreach ($existing as $req) {
|
||||
$existingByTypeId[$req->getTypeProduct()->getId()][] = $req;
|
||||
}
|
||||
|
||||
$matched = [];
|
||||
$toCreate = [];
|
||||
|
||||
foreach ($proposedProducts as $i => $prodData) {
|
||||
$typeId = $prodData['typeProductId'];
|
||||
if (!empty($existingByTypeId[$typeId])) {
|
||||
$req = array_shift($existingByTypeId[$typeId]);
|
||||
$req->setFamilyCode($prodData['familyCode'] ?? null);
|
||||
$req->setPosition($i);
|
||||
$matched[spl_object_id($req)] = true;
|
||||
} else {
|
||||
$toCreate[] = ['data' => $prodData, 'position' => $i];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($existing as $req) {
|
||||
if (!isset($matched[spl_object_id($req)])) {
|
||||
$modelType->removeSkeletonProductRequirement($req);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($toCreate as $item) {
|
||||
$req = new SkeletonProductRequirement();
|
||||
$req->setModelType($modelType);
|
||||
$req->setTypeProduct($this->em->getReference(ModelType::class, $item['data']['typeProductId']));
|
||||
$req->setFamilyCode($item['data']['familyCode'] ?? null);
|
||||
$req->setPosition($item['position']);
|
||||
$modelType->addSkeletonProductRequirement($req);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{alias?: string, familyCode?: string, typeComposantId?: string}> $proposedSubs
|
||||
*/
|
||||
private function syncSubcomponentRequirements(ModelType $modelType, array $proposedSubs): void
|
||||
{
|
||||
$existing = $modelType->getSkeletonSubcomponentRequirements()->toArray();
|
||||
|
||||
$existingByTypeId = [];
|
||||
foreach ($existing as $req) {
|
||||
$key = $req->getTypeComposant()?->getId() ?? '';
|
||||
$existingByTypeId[$key][] = $req;
|
||||
}
|
||||
|
||||
$matched = [];
|
||||
$toCreate = [];
|
||||
|
||||
foreach ($proposedSubs as $i => $subData) {
|
||||
$typeId = $subData['typeComposantId'] ?? '';
|
||||
if (!empty($existingByTypeId[$typeId])) {
|
||||
$req = array_shift($existingByTypeId[$typeId]);
|
||||
$req->setAlias($subData['alias'] ?? '');
|
||||
$req->setFamilyCode($subData['familyCode'] ?? '');
|
||||
if (!empty($subData['typeComposantId'])) {
|
||||
$req->setTypeComposant($this->em->getReference(ModelType::class, $subData['typeComposantId']));
|
||||
}
|
||||
$req->setPosition($i);
|
||||
$matched[spl_object_id($req)] = true;
|
||||
} else {
|
||||
$toCreate[] = ['data' => $subData, 'position' => $i];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($existing as $req) {
|
||||
if (!isset($matched[spl_object_id($req)])) {
|
||||
$modelType->removeSkeletonSubcomponentRequirement($req);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($toCreate as $item) {
|
||||
$req = new SkeletonSubcomponentRequirement();
|
||||
$req->setModelType($modelType);
|
||||
$req->setAlias($item['data']['alias'] ?? '');
|
||||
$req->setFamilyCode($item['data']['familyCode'] ?? '');
|
||||
if (!empty($item['data']['typeComposantId'])) {
|
||||
$req->setTypeComposant($this->em->getReference(ModelType::class, $item['data']['typeComposantId']));
|
||||
}
|
||||
$req->setPosition($item['position']);
|
||||
$modelType->addSkeletonSubcomponentRequirement($req);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync CustomField entities for this ModelType.
|
||||
* Handles two frontend formats:
|
||||
|
||||
@@ -51,23 +51,20 @@ class ComposantSyncStrategy implements SyncStrategyInterface
|
||||
$addedCfValues = 0;
|
||||
$deletedCfValues = 0;
|
||||
|
||||
// Map proposed by (typeId, position) keys — position defaults to array index
|
||||
$proposedPieceKeys = [];
|
||||
foreach ($proposedPieces as $i => $pp) {
|
||||
$pos = $pp['position'] ?? $i;
|
||||
$proposedPieceKeys[$pp['typePieceId'].'|'.$pos] = true;
|
||||
// Build proposed typeId lists (one entry per requirement, order = position)
|
||||
$proposedPieceTypeIds = [];
|
||||
foreach ($proposedPieces as $pp) {
|
||||
$proposedPieceTypeIds[] = $pp['typePieceId'];
|
||||
}
|
||||
|
||||
$proposedProductKeys = [];
|
||||
foreach ($proposedProducts as $i => $pp) {
|
||||
$pos = $pp['position'] ?? $i;
|
||||
$proposedProductKeys[$pp['typeProductId'].'|'.$pos] = true;
|
||||
$proposedProductTypeIds = [];
|
||||
foreach ($proposedProducts as $pp) {
|
||||
$proposedProductTypeIds[] = $pp['typeProductId'];
|
||||
}
|
||||
|
||||
$proposedSubKeys = [];
|
||||
foreach ($proposedSubcomponents as $i => $ps) {
|
||||
$pos = $ps['position'] ?? $i;
|
||||
$proposedSubKeys[$ps['typeComposantId'].'|'.$pos] = true;
|
||||
$proposedSubTypeIds = [];
|
||||
foreach ($proposedSubcomponents as $ps) {
|
||||
$proposedSubTypeIds[] = $ps['typeComposantId'];
|
||||
}
|
||||
|
||||
// Map proposed custom fields by orderIndex (falls back to array index)
|
||||
@@ -102,59 +99,26 @@ class ComposantSyncStrategy implements SyncStrategyInterface
|
||||
}
|
||||
|
||||
foreach ($composants as $composant) {
|
||||
// Piece slots — query from repository to avoid stale collection
|
||||
$pieceSlots = $this->em->getRepository(ComposantPieceSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingPieceKeys = [];
|
||||
foreach ($pieceSlots as $slot) {
|
||||
$key = ($slot->getTypePiece()?->getId() ?? '').'|'.$slot->getPosition();
|
||||
$existingPieceKeys[$key] = true;
|
||||
}
|
||||
foreach ($proposedPieceKeys as $key => $_) {
|
||||
if (!isset($existingPieceKeys[$key])) {
|
||||
++$addedPieceSlots;
|
||||
}
|
||||
}
|
||||
foreach ($existingPieceKeys as $key => $_) {
|
||||
if (!isset($proposedPieceKeys[$key])) {
|
||||
++$deletedPieceSlots;
|
||||
}
|
||||
}
|
||||
// Piece slots
|
||||
$pieceSlots = $this->em->getRepository(ComposantPieceSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingPieceTypes = array_map(fn (ComposantPieceSlot $s) => $s->getTypePiece()?->getId() ?? '', $pieceSlots);
|
||||
$result = $this->smartMatchPreview($existingPieceTypes, $proposedPieceTypeIds);
|
||||
$addedPieceSlots += $result['added'];
|
||||
$deletedPieceSlots += $result['deleted'];
|
||||
|
||||
// Product slots
|
||||
$productSlots = $this->em->getRepository(ComposantProductSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingProductKeys = [];
|
||||
foreach ($productSlots as $slot) {
|
||||
$key = ($slot->getTypeProduct()?->getId() ?? '').'|'.$slot->getPosition();
|
||||
$existingProductKeys[$key] = true;
|
||||
}
|
||||
foreach ($proposedProductKeys as $key => $_) {
|
||||
if (!isset($existingProductKeys[$key])) {
|
||||
++$addedProductSlots;
|
||||
}
|
||||
}
|
||||
foreach ($existingProductKeys as $key => $_) {
|
||||
if (!isset($proposedProductKeys[$key])) {
|
||||
++$deletedProductSlots;
|
||||
}
|
||||
}
|
||||
$productSlots = $this->em->getRepository(ComposantProductSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingProductTypes = array_map(fn (ComposantProductSlot $s) => $s->getTypeProduct()?->getId() ?? '', $productSlots);
|
||||
$result = $this->smartMatchPreview($existingProductTypes, $proposedProductTypeIds);
|
||||
$addedProductSlots += $result['added'];
|
||||
$deletedProductSlots += $result['deleted'];
|
||||
|
||||
// Subcomponent slots
|
||||
$subSlots = $this->em->getRepository(ComposantSubcomponentSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingSubKeys = [];
|
||||
foreach ($subSlots as $slot) {
|
||||
$key = ($slot->getTypeComposant()?->getId() ?? '').'|'.$slot->getPosition();
|
||||
$existingSubKeys[$key] = true;
|
||||
}
|
||||
foreach ($proposedSubKeys as $key => $_) {
|
||||
if (!isset($existingSubKeys[$key])) {
|
||||
++$addedSubSlots;
|
||||
}
|
||||
}
|
||||
foreach ($existingSubKeys as $key => $_) {
|
||||
if (!isset($proposedSubKeys[$key])) {
|
||||
++$deletedSubSlots;
|
||||
}
|
||||
}
|
||||
$subSlots = $this->em->getRepository(ComposantSubcomponentSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingSubTypes = array_map(fn (ComposantSubcomponentSlot $s) => $s->getTypeComposant()?->getId() ?? '', $subSlots);
|
||||
$result = $this->smartMatchPreview($existingSubTypes, $proposedSubTypeIds);
|
||||
$addedSubSlots += $result['added'];
|
||||
$deletedSubSlots += $result['deleted'];
|
||||
|
||||
// Custom field values
|
||||
$addedCfValues += $cfAdded;
|
||||
@@ -187,31 +151,14 @@ class ComposantSyncStrategy implements SyncStrategyInterface
|
||||
$composants = $this->em->getRepository(Composant::class)->findBy(['typeComposant' => $modelType]);
|
||||
|
||||
// Load skeleton requirements
|
||||
$pieceReqs = $this->em->getRepository(SkeletonPieceRequirement::class)->findBy(['modelType' => $modelType]);
|
||||
$productReqs = $this->em->getRepository(SkeletonProductRequirement::class)->findBy(['modelType' => $modelType]);
|
||||
$subReqs = $this->em->getRepository(SkeletonSubcomponentRequirement::class)->findBy(['modelType' => $modelType]);
|
||||
$pieceReqs = $this->em->getRepository(SkeletonPieceRequirement::class)->findBy(['modelType' => $modelType], ['position' => 'ASC']);
|
||||
$productReqs = $this->em->getRepository(SkeletonProductRequirement::class)->findBy(['modelType' => $modelType], ['position' => 'ASC']);
|
||||
$subReqs = $this->em->getRepository(SkeletonSubcomponentRequirement::class)->findBy(['modelType' => $modelType], ['position' => 'ASC']);
|
||||
$customFields = $this->em->getRepository(CustomField::class)->findBy(
|
||||
['typeComposant' => $modelType],
|
||||
['orderIndex' => 'ASC']
|
||||
);
|
||||
|
||||
// Map requirements by (typeId, position)
|
||||
$pieceReqKeys = [];
|
||||
foreach ($pieceReqs as $req) {
|
||||
$pieceReqKeys[$req->getTypePiece()->getId().'|'.$req->getPosition()] = $req;
|
||||
}
|
||||
|
||||
$productReqKeys = [];
|
||||
foreach ($productReqs as $req) {
|
||||
$productReqKeys[$req->getTypeProduct()->getId().'|'.$req->getPosition()] = $req;
|
||||
}
|
||||
|
||||
$subReqKeys = [];
|
||||
foreach ($subReqs as $req) {
|
||||
$key = ($req->getTypeComposant()?->getId() ?? '').'|'.$req->getPosition();
|
||||
$subReqKeys[$key] = $req;
|
||||
}
|
||||
|
||||
$addedPieceSlots = 0;
|
||||
$deletedPieceSlots = 0;
|
||||
$addedProductSlots = 0;
|
||||
@@ -225,108 +172,137 @@ class ComposantSyncStrategy implements SyncStrategyInterface
|
||||
foreach ($composants as $composant) {
|
||||
$changed = false;
|
||||
|
||||
// --- Piece slots — query from repository to avoid stale collection ---
|
||||
$pieceSlotEntities = $this->em->getRepository(ComposantPieceSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingPieceSlots = [];
|
||||
foreach ($pieceSlotEntities as $slot) {
|
||||
$key = ($slot->getTypePiece()?->getId() ?? '').'|'.$slot->getPosition();
|
||||
$existingPieceSlots[$key] = $slot;
|
||||
}
|
||||
// --- Piece slots ---
|
||||
$pieceSlotEntities = $this->em->getRepository(ComposantPieceSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingPieceTypeIds = array_map(fn (ComposantPieceSlot $s) => $s->getTypePiece()?->getId() ?? '', $pieceSlotEntities);
|
||||
$reqPieceTypeIds = array_map(fn (SkeletonPieceRequirement $r) => $r->getTypePiece()->getId(), $pieceReqs);
|
||||
$matchResult = $this->smartMatch($existingPieceTypeIds, $reqPieceTypeIds);
|
||||
|
||||
// Add missing piece slots
|
||||
foreach ($pieceReqKeys as $key => $req) {
|
||||
if (!isset($existingPieceSlots[$key])) {
|
||||
$slot = new ComposantPieceSlot();
|
||||
$slot->setComposant($composant);
|
||||
$slot->setTypePiece($req->getTypePiece());
|
||||
// Update matched slots (position may have changed)
|
||||
foreach ($matchResult['matched'] as [$slotIdx, $reqIdx]) {
|
||||
$slot = $pieceSlotEntities[$slotIdx];
|
||||
$req = $pieceReqs[$reqIdx];
|
||||
if ($slot->getPosition() !== $req->getPosition()) {
|
||||
$slot->setPosition($req->getPosition());
|
||||
// Default quantity = 1, selectedPiece = null (already defaults)
|
||||
$this->em->persist($slot);
|
||||
++$addedPieceSlots;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new piece slots for unmatched requirements
|
||||
foreach ($matchResult['unmatchedReqs'] as $reqIdx) {
|
||||
$req = $pieceReqs[$reqIdx];
|
||||
$slot = new ComposantPieceSlot();
|
||||
$slot->setComposant($composant);
|
||||
$slot->setTypePiece($req->getTypePiece());
|
||||
$slot->setPosition($req->getPosition());
|
||||
$this->em->persist($slot);
|
||||
++$addedPieceSlots;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
// Delete orphaned piece slots
|
||||
if ($confirmation->confirmDeletions) {
|
||||
foreach ($existingPieceSlots as $key => $slot) {
|
||||
if (!isset($pieceReqKeys[$key])) {
|
||||
$composant->removePieceSlot($slot);
|
||||
$this->em->remove($slot);
|
||||
++$deletedPieceSlots;
|
||||
$changed = true;
|
||||
}
|
||||
foreach ($matchResult['orphanedSlots'] as $slotIdx) {
|
||||
$slot = $pieceSlotEntities[$slotIdx];
|
||||
$composant->removePieceSlot($slot);
|
||||
$this->em->remove($slot);
|
||||
++$deletedPieceSlots;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Product slots ---
|
||||
$productSlotEntities = $this->em->getRepository(ComposantProductSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingProductSlots = [];
|
||||
foreach ($productSlotEntities as $slot) {
|
||||
$key = ($slot->getTypeProduct()?->getId() ?? '').'|'.$slot->getPosition();
|
||||
$existingProductSlots[$key] = $slot;
|
||||
}
|
||||
$productSlotEntities = $this->em->getRepository(ComposantProductSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingProductTypeIds = array_map(fn (ComposantProductSlot $s) => $s->getTypeProduct()?->getId() ?? '', $productSlotEntities);
|
||||
$reqProductTypeIds = array_map(fn (SkeletonProductRequirement $r) => $r->getTypeProduct()->getId(), $productReqs);
|
||||
$matchResult = $this->smartMatch($existingProductTypeIds, $reqProductTypeIds);
|
||||
|
||||
// Add missing product slots
|
||||
foreach ($productReqKeys as $key => $req) {
|
||||
if (!isset($existingProductSlots[$key])) {
|
||||
$slot = new ComposantProductSlot();
|
||||
$slot->setComposant($composant);
|
||||
$slot->setTypeProduct($req->getTypeProduct());
|
||||
// Update matched slots
|
||||
foreach ($matchResult['matched'] as [$slotIdx, $reqIdx]) {
|
||||
$slot = $productSlotEntities[$slotIdx];
|
||||
$req = $productReqs[$reqIdx];
|
||||
if ($slot->getPosition() !== $req->getPosition()) {
|
||||
$slot->setPosition($req->getPosition());
|
||||
if (null !== $req->getFamilyCode()) {
|
||||
$slot->setFamilyCode($req->getFamilyCode());
|
||||
}
|
||||
$this->em->persist($slot);
|
||||
++$addedProductSlots;
|
||||
$changed = true;
|
||||
}
|
||||
if ($slot->getFamilyCode() !== $req->getFamilyCode()) {
|
||||
$slot->setFamilyCode($req->getFamilyCode());
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new product slots
|
||||
foreach ($matchResult['unmatchedReqs'] as $reqIdx) {
|
||||
$req = $productReqs[$reqIdx];
|
||||
$slot = new ComposantProductSlot();
|
||||
$slot->setComposant($composant);
|
||||
$slot->setTypeProduct($req->getTypeProduct());
|
||||
$slot->setPosition($req->getPosition());
|
||||
if (null !== $req->getFamilyCode()) {
|
||||
$slot->setFamilyCode($req->getFamilyCode());
|
||||
}
|
||||
$this->em->persist($slot);
|
||||
++$addedProductSlots;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
// Delete orphaned product slots
|
||||
if ($confirmation->confirmDeletions) {
|
||||
foreach ($existingProductSlots as $key => $slot) {
|
||||
if (!isset($productReqKeys[$key])) {
|
||||
$composant->removeProductSlot($slot);
|
||||
$this->em->remove($slot);
|
||||
++$deletedProductSlots;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Subcomponent slots ---
|
||||
$subSlotEntities = $this->em->getRepository(ComposantSubcomponentSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingSubSlots = [];
|
||||
foreach ($subSlotEntities as $slot) {
|
||||
$key = ($slot->getTypeComposant()?->getId() ?? '').'|'.$slot->getPosition();
|
||||
$existingSubSlots[$key] = $slot;
|
||||
}
|
||||
|
||||
// Add missing subcomponent slots
|
||||
foreach ($subReqKeys as $key => $req) {
|
||||
if (!isset($existingSubSlots[$key])) {
|
||||
$slot = new ComposantSubcomponentSlot();
|
||||
$slot->setComposant($composant);
|
||||
$slot->setTypeComposant($req->getTypeComposant());
|
||||
$slot->setPosition($req->getPosition());
|
||||
$slot->setAlias($req->getAlias());
|
||||
$slot->setFamilyCode($req->getFamilyCode());
|
||||
$this->em->persist($slot);
|
||||
++$addedSubSlots;
|
||||
foreach ($matchResult['orphanedSlots'] as $slotIdx) {
|
||||
$slot = $productSlotEntities[$slotIdx];
|
||||
$composant->removeProductSlot($slot);
|
||||
$this->em->remove($slot);
|
||||
++$deletedProductSlots;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Subcomponent slots ---
|
||||
$subSlotEntities = $this->em->getRepository(ComposantSubcomponentSlot::class)->findBy(['composant' => $composant]);
|
||||
$existingSubTypeIds = array_map(fn (ComposantSubcomponentSlot $s) => $s->getTypeComposant()?->getId() ?? '', $subSlotEntities);
|
||||
$reqSubTypeIds = array_map(fn (SkeletonSubcomponentRequirement $r) => $r->getTypeComposant()?->getId() ?? '', $subReqs);
|
||||
$matchResult = $this->smartMatch($existingSubTypeIds, $reqSubTypeIds);
|
||||
|
||||
// Update matched slots
|
||||
foreach ($matchResult['matched'] as [$slotIdx, $reqIdx]) {
|
||||
$slot = $subSlotEntities[$slotIdx];
|
||||
$req = $subReqs[$reqIdx];
|
||||
if ($slot->getPosition() !== $req->getPosition()) {
|
||||
$slot->setPosition($req->getPosition());
|
||||
$changed = true;
|
||||
}
|
||||
if ($slot->getAlias() !== $req->getAlias()) {
|
||||
$slot->setAlias($req->getAlias());
|
||||
$changed = true;
|
||||
}
|
||||
if ($slot->getFamilyCode() !== $req->getFamilyCode()) {
|
||||
$slot->setFamilyCode($req->getFamilyCode());
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new subcomponent slots
|
||||
foreach ($matchResult['unmatchedReqs'] as $reqIdx) {
|
||||
$req = $subReqs[$reqIdx];
|
||||
$slot = new ComposantSubcomponentSlot();
|
||||
$slot->setComposant($composant);
|
||||
$slot->setTypeComposant($req->getTypeComposant());
|
||||
$slot->setPosition($req->getPosition());
|
||||
$slot->setAlias($req->getAlias());
|
||||
$slot->setFamilyCode($req->getFamilyCode());
|
||||
$this->em->persist($slot);
|
||||
++$addedSubSlots;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
// Delete orphaned subcomponent slots
|
||||
if ($confirmation->confirmDeletions) {
|
||||
foreach ($existingSubSlots as $key => $slot) {
|
||||
if (!isset($subReqKeys[$key])) {
|
||||
$composant->removeSubcomponentSlot($slot);
|
||||
$this->em->remove($slot);
|
||||
++$deletedSubSlots;
|
||||
$changed = true;
|
||||
}
|
||||
foreach ($matchResult['orphanedSlots'] as $slotIdx) {
|
||||
$slot = $subSlotEntities[$slotIdx];
|
||||
$composant->removeSubcomponentSlot($slot);
|
||||
$this->em->remove($slot);
|
||||
++$deletedSubSlots;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,4 +365,83 @@ class ComposantSyncStrategy implements SyncStrategyInterface
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart-match existing slots to proposed requirements by typeId.
|
||||
*
|
||||
* Pass 1: exact match by typeId + position index.
|
||||
* Pass 2: match remaining by typeId only (handles reordering/insertion).
|
||||
*
|
||||
* @param string[] $existingTypeIds typeIds of existing slots (index = slot index)
|
||||
* @param string[] $proposedTypeIds typeIds of proposed requirements (index = req index)
|
||||
*
|
||||
* @return array{matched: list<array{int, int}>, orphanedSlots: int[], unmatchedReqs: int[]}
|
||||
*/
|
||||
private function smartMatch(array $existingTypeIds, array $proposedTypeIds): array
|
||||
{
|
||||
$matchedSlots = [];
|
||||
$matchedReqs = [];
|
||||
$matched = [];
|
||||
|
||||
// Pass 1: exact match where typeId AND position index are identical
|
||||
foreach ($proposedTypeIds as $reqIdx => $reqTypeId) {
|
||||
if (isset($existingTypeIds[$reqIdx]) && $existingTypeIds[$reqIdx] === $reqTypeId && !isset($matchedSlots[$reqIdx])) {
|
||||
$matched[] = [$reqIdx, $reqIdx];
|
||||
$matchedSlots[$reqIdx] = true;
|
||||
$matchedReqs[$reqIdx] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: match remaining by typeId only (preserves selections on reorder)
|
||||
$remainingSlotsByType = [];
|
||||
foreach ($existingTypeIds as $slotIdx => $typeId) {
|
||||
if (!isset($matchedSlots[$slotIdx])) {
|
||||
$remainingSlotsByType[$typeId][] = $slotIdx;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($proposedTypeIds as $reqIdx => $reqTypeId) {
|
||||
if (!isset($matchedReqs[$reqIdx]) && !empty($remainingSlotsByType[$reqTypeId])) {
|
||||
$slotIdx = array_shift($remainingSlotsByType[$reqTypeId]);
|
||||
$matched[] = [$slotIdx, $reqIdx];
|
||||
$matchedSlots[$slotIdx] = true;
|
||||
$matchedReqs[$reqIdx] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect unmatched
|
||||
$orphanedSlots = [];
|
||||
foreach ($existingTypeIds as $slotIdx => $_) {
|
||||
if (!isset($matchedSlots[$slotIdx])) {
|
||||
$orphanedSlots[] = $slotIdx;
|
||||
}
|
||||
}
|
||||
|
||||
$unmatchedReqs = [];
|
||||
foreach ($proposedTypeIds as $reqIdx => $_) {
|
||||
if (!isset($matchedReqs[$reqIdx])) {
|
||||
$unmatchedReqs[] = $reqIdx;
|
||||
}
|
||||
}
|
||||
|
||||
return ['matched' => $matched, 'orphanedSlots' => $orphanedSlots, 'unmatchedReqs' => $unmatchedReqs];
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview version of smart matching — counts additions and deletions.
|
||||
*
|
||||
* @param string[] $existingTypeIds
|
||||
* @param string[] $proposedTypeIds
|
||||
*
|
||||
* @return array{added: int, deleted: int}
|
||||
*/
|
||||
private function smartMatchPreview(array $existingTypeIds, array $proposedTypeIds): array
|
||||
{
|
||||
$result = $this->smartMatch($existingTypeIds, $proposedTypeIds);
|
||||
|
||||
return [
|
||||
'added' => count($result['unmatchedReqs']),
|
||||
'deleted' => count($result['orphanedSlots']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,11 +41,10 @@ class PieceSyncStrategy implements SyncStrategyInterface
|
||||
$addedCfValues = 0;
|
||||
$deletedCfValues = 0;
|
||||
|
||||
// Map proposed products by (typeProductId, position) keys — position defaults to array index
|
||||
$proposedProductKeys = [];
|
||||
foreach ($proposedProducts as $i => $pp) {
|
||||
$pos = $pp['position'] ?? $i;
|
||||
$proposedProductKeys[$pp['typeProductId'].'|'.$pos] = true;
|
||||
// Build proposed typeId list
|
||||
$proposedProductTypeIds = [];
|
||||
foreach ($proposedProducts as $pp) {
|
||||
$proposedProductTypeIds[] = $pp['typeProductId'];
|
||||
}
|
||||
|
||||
// Map proposed custom fields by orderIndex (falls back to array index)
|
||||
@@ -80,23 +79,12 @@ class PieceSyncStrategy implements SyncStrategyInterface
|
||||
}
|
||||
|
||||
foreach ($pieces as $piece) {
|
||||
// Product slots
|
||||
$productSlots = $this->em->getRepository(PieceProductSlot::class)->findBy(['piece' => $piece]);
|
||||
$existingProductKeys = [];
|
||||
foreach ($productSlots as $slot) {
|
||||
$key = ($slot->getTypeProduct()?->getId() ?? '').'|'.$slot->getPosition();
|
||||
$existingProductKeys[$key] = true;
|
||||
}
|
||||
foreach ($proposedProductKeys as $key => $_) {
|
||||
if (!isset($existingProductKeys[$key])) {
|
||||
++$addedProductSlots;
|
||||
}
|
||||
}
|
||||
foreach ($existingProductKeys as $key => $_) {
|
||||
if (!isset($proposedProductKeys[$key])) {
|
||||
++$deletedProductSlots;
|
||||
}
|
||||
}
|
||||
// Product slots — smart matching by typeId
|
||||
$productSlots = $this->em->getRepository(PieceProductSlot::class)->findBy(['piece' => $piece]);
|
||||
$existingProductTypes = array_map(fn (PieceProductSlot $s) => $s->getTypeProduct()?->getId() ?? '', $productSlots);
|
||||
$result = $this->smartMatchPreview($existingProductTypes, $proposedProductTypeIds);
|
||||
$addedProductSlots += $result['added'];
|
||||
$deletedProductSlots += $result['deleted'];
|
||||
|
||||
// Custom field values
|
||||
$addedCfValues += $cfAdded;
|
||||
@@ -125,18 +113,12 @@ class PieceSyncStrategy implements SyncStrategyInterface
|
||||
$pieces = $this->em->getRepository(Piece::class)->findBy(['typePiece' => $modelType]);
|
||||
|
||||
// Load skeleton requirements
|
||||
$productReqs = $this->em->getRepository(SkeletonProductRequirement::class)->findBy(['modelType' => $modelType]);
|
||||
$productReqs = $this->em->getRepository(SkeletonProductRequirement::class)->findBy(['modelType' => $modelType], ['position' => 'ASC']);
|
||||
$customFields = $this->em->getRepository(CustomField::class)->findBy(
|
||||
['typePiece' => $modelType],
|
||||
['orderIndex' => 'ASC']
|
||||
);
|
||||
|
||||
// Map requirements by (typeProductId, position)
|
||||
$productReqKeys = [];
|
||||
foreach ($productReqs as $req) {
|
||||
$productReqKeys[$req->getTypeProduct()->getId().'|'.$req->getPosition()] = $req;
|
||||
}
|
||||
|
||||
$addedProductSlots = 0;
|
||||
$deletedProductSlots = 0;
|
||||
$addedCfValues = 0;
|
||||
@@ -147,38 +129,48 @@ class PieceSyncStrategy implements SyncStrategyInterface
|
||||
$changed = false;
|
||||
|
||||
// --- Product slots ---
|
||||
$productSlotEntities = $this->em->getRepository(PieceProductSlot::class)->findBy(['piece' => $piece]);
|
||||
$existingProductSlots = [];
|
||||
foreach ($productSlotEntities as $slot) {
|
||||
$key = ($slot->getTypeProduct()?->getId() ?? '').'|'.$slot->getPosition();
|
||||
$existingProductSlots[$key] = $slot;
|
||||
}
|
||||
$productSlotEntities = $this->em->getRepository(PieceProductSlot::class)->findBy(['piece' => $piece]);
|
||||
$existingProductTypeIds = array_map(fn (PieceProductSlot $s) => $s->getTypeProduct()?->getId() ?? '', $productSlotEntities);
|
||||
$reqProductTypeIds = array_map(fn (SkeletonProductRequirement $r) => $r->getTypeProduct()->getId(), $productReqs);
|
||||
$matchResult = $this->smartMatch($existingProductTypeIds, $reqProductTypeIds);
|
||||
|
||||
// Add missing product slots
|
||||
foreach ($productReqKeys as $key => $req) {
|
||||
if (!isset($existingProductSlots[$key])) {
|
||||
$slot = new PieceProductSlot();
|
||||
$slot->setPiece($piece);
|
||||
$slot->setTypeProduct($req->getTypeProduct());
|
||||
// Update matched slots (position/familyCode may have changed)
|
||||
foreach ($matchResult['matched'] as [$slotIdx, $reqIdx]) {
|
||||
$slot = $productSlotEntities[$slotIdx];
|
||||
$req = $productReqs[$reqIdx];
|
||||
if ($slot->getPosition() !== $req->getPosition()) {
|
||||
$slot->setPosition($req->getPosition());
|
||||
if (null !== $req->getFamilyCode()) {
|
||||
$slot->setFamilyCode($req->getFamilyCode());
|
||||
}
|
||||
$this->em->persist($slot);
|
||||
++$addedProductSlots;
|
||||
$changed = true;
|
||||
}
|
||||
if ($slot->getFamilyCode() !== $req->getFamilyCode()) {
|
||||
$slot->setFamilyCode($req->getFamilyCode());
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new product slots
|
||||
foreach ($matchResult['unmatchedReqs'] as $reqIdx) {
|
||||
$req = $productReqs[$reqIdx];
|
||||
$slot = new PieceProductSlot();
|
||||
$slot->setPiece($piece);
|
||||
$slot->setTypeProduct($req->getTypeProduct());
|
||||
$slot->setPosition($req->getPosition());
|
||||
if (null !== $req->getFamilyCode()) {
|
||||
$slot->setFamilyCode($req->getFamilyCode());
|
||||
}
|
||||
$this->em->persist($slot);
|
||||
++$addedProductSlots;
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
// Delete orphaned product slots
|
||||
if ($confirmation->confirmDeletions) {
|
||||
foreach ($existingProductSlots as $key => $slot) {
|
||||
if (!isset($productReqKeys[$key])) {
|
||||
$piece->removeProductSlot($slot);
|
||||
$this->em->remove($slot);
|
||||
++$deletedProductSlots;
|
||||
$changed = true;
|
||||
}
|
||||
foreach ($matchResult['orphanedSlots'] as $slotIdx) {
|
||||
$slot = $productSlotEntities[$slotIdx];
|
||||
$piece->removeProductSlot($slot);
|
||||
$this->em->remove($slot);
|
||||
++$deletedProductSlots;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,4 +229,81 @@ class PieceSyncStrategy implements SyncStrategyInterface
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart-match existing slots to proposed requirements by typeId.
|
||||
*
|
||||
* Pass 1: exact match by typeId + position index.
|
||||
* Pass 2: match remaining by typeId only (handles reordering/insertion).
|
||||
*
|
||||
* @param string[] $existingTypeIds typeIds of existing slots (index = slot index)
|
||||
* @param string[] $proposedTypeIds typeIds of proposed requirements (index = req index)
|
||||
*
|
||||
* @return array{matched: list<array{int, int}>, orphanedSlots: int[], unmatchedReqs: int[]}
|
||||
*/
|
||||
private function smartMatch(array $existingTypeIds, array $proposedTypeIds): array
|
||||
{
|
||||
$matchedSlots = [];
|
||||
$matchedReqs = [];
|
||||
$matched = [];
|
||||
|
||||
// Pass 1: exact match where typeId AND position index are identical
|
||||
foreach ($proposedTypeIds as $reqIdx => $reqTypeId) {
|
||||
if (isset($existingTypeIds[$reqIdx]) && $existingTypeIds[$reqIdx] === $reqTypeId && !isset($matchedSlots[$reqIdx])) {
|
||||
$matched[] = [$reqIdx, $reqIdx];
|
||||
$matchedSlots[$reqIdx] = true;
|
||||
$matchedReqs[$reqIdx] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: match remaining by typeId only (preserves selections on reorder)
|
||||
$remainingSlotsByType = [];
|
||||
foreach ($existingTypeIds as $slotIdx => $typeId) {
|
||||
if (!isset($matchedSlots[$slotIdx])) {
|
||||
$remainingSlotsByType[$typeId][] = $slotIdx;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($proposedTypeIds as $reqIdx => $reqTypeId) {
|
||||
if (!isset($matchedReqs[$reqIdx]) && !empty($remainingSlotsByType[$reqTypeId])) {
|
||||
$slotIdx = array_shift($remainingSlotsByType[$reqTypeId]);
|
||||
$matched[] = [$slotIdx, $reqIdx];
|
||||
$matchedSlots[$slotIdx] = true;
|
||||
$matchedReqs[$reqIdx] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect unmatched
|
||||
$orphanedSlots = [];
|
||||
foreach ($existingTypeIds as $slotIdx => $_) {
|
||||
if (!isset($matchedSlots[$slotIdx])) {
|
||||
$orphanedSlots[] = $slotIdx;
|
||||
}
|
||||
}
|
||||
|
||||
$unmatchedReqs = [];
|
||||
foreach ($proposedTypeIds as $reqIdx => $_) {
|
||||
if (!isset($matchedReqs[$reqIdx])) {
|
||||
$unmatchedReqs[] = $reqIdx;
|
||||
}
|
||||
}
|
||||
|
||||
return ['matched' => $matched, 'orphanedSlots' => $orphanedSlots, 'unmatchedReqs' => $unmatchedReqs];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $existingTypeIds
|
||||
* @param string[] $proposedTypeIds
|
||||
*
|
||||
* @return array{added: int, deleted: int}
|
||||
*/
|
||||
private function smartMatchPreview(array $existingTypeIds, array $proposedTypeIds): array
|
||||
{
|
||||
$result = $this->smartMatch($existingTypeIds, $proposedTypeIds);
|
||||
|
||||
return [
|
||||
'added' => count($result['unmatchedReqs']),
|
||||
'deleted' => count($result['orphanedSlots']),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
15
symfony.lock
15
symfony.lock
@@ -94,6 +94,18 @@
|
||||
"config/packages/nelmio_cors.yaml"
|
||||
]
|
||||
},
|
||||
"php-http/discovery": {
|
||||
"version": "1.20",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.18",
|
||||
"ref": "f45b5dd173a27873ab19f5e3180b2f661c21de02"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/http_discovery.yaml"
|
||||
]
|
||||
},
|
||||
"phpunit/phpunit": {
|
||||
"version": "12.5",
|
||||
"recipe": {
|
||||
@@ -154,6 +166,9 @@
|
||||
".editorconfig"
|
||||
]
|
||||
},
|
||||
"symfony/mcp-bundle": {
|
||||
"version": "v0.6.0"
|
||||
},
|
||||
"symfony/property-info": {
|
||||
"version": "8.0",
|
||||
"recipe": {
|
||||
|
||||
@@ -25,6 +25,7 @@ use App\Entity\Profile;
|
||||
use App\Entity\Site;
|
||||
use App\Enum\ModelCategory;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use stdClass;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
abstract class AbstractApiTestCase extends ApiTestCase
|
||||
@@ -85,6 +86,93 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
||||
return static::createClient();
|
||||
}
|
||||
|
||||
// ── MCP helpers ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @return array{client: Client, sessionId: string}
|
||||
*/
|
||||
protected function createMcpClient(string $role = 'ROLE_VIEWER'): array
|
||||
{
|
||||
$profile = $this->createProfile(roles: [$role], password: self::DEFAULT_PASSWORD);
|
||||
|
||||
return $this->initMcpSession($profile->getId(), self::DEFAULT_PASSWORD);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{client: Client, sessionId: string}
|
||||
*/
|
||||
protected function initMcpSession(string $profileId, string $password): array
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$response = $client->request('POST', '/_mcp', [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Profile-Id' => $profileId,
|
||||
'X-Profile-Password' => $password,
|
||||
],
|
||||
'body' => json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => 'initialize',
|
||||
'params' => [
|
||||
'protocolVersion' => '2025-03-26',
|
||||
'capabilities' => new stdClass(),
|
||||
'clientInfo' => ['name' => 'test', 'version' => '1.0'],
|
||||
],
|
||||
'id' => 1,
|
||||
]),
|
||||
]);
|
||||
|
||||
$sessionId = $response->getHeaders()['mcp-session-id'][0] ?? '';
|
||||
|
||||
$client->request('POST', '/_mcp', [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Profile-Id' => $profileId,
|
||||
'X-Profile-Password' => $password,
|
||||
'Mcp-Session-Id' => $sessionId,
|
||||
],
|
||||
'body' => json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => 'notifications/initialized',
|
||||
]),
|
||||
]);
|
||||
|
||||
return ['client' => $client, 'sessionId' => $sessionId, 'profileId' => $profileId, 'password' => $password];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function callMcpTool(array $session, string $toolName, array $arguments = []): array
|
||||
{
|
||||
$response = $session['client']->request('POST', '/_mcp', [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Profile-Id' => $session['profileId'],
|
||||
'X-Profile-Password' => $session['password'],
|
||||
'Mcp-Session-Id' => $session['sessionId'],
|
||||
],
|
||||
'body' => json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => $toolName,
|
||||
'arguments' => empty($arguments) ? new stdClass() : $arguments,
|
||||
],
|
||||
'id' => random_int(10, 9999),
|
||||
]),
|
||||
]);
|
||||
|
||||
$data = $response->toArray(false);
|
||||
|
||||
if (isset($data['result']['content'][0]['text'])) {
|
||||
$data['_parsed'] = json_decode($data['result']['content'][0]['text'], true);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
// ── Factory helpers ─────────────────────────────────────────────
|
||||
|
||||
protected function createProfile(
|
||||
|
||||
84
tests/Mcp/Security/McpHeaderAuthenticatorTest.php
Normal file
84
tests/Mcp/Security/McpHeaderAuthenticatorTest.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Security;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class McpHeaderAuthenticatorTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testMcpEndpointRejectsWithoutCredentials(): void
|
||||
{
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('POST', '/_mcp', [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'body' => $this->mcpRequest(),
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testMcpEndpointRejectsInvalidPassword(): void
|
||||
{
|
||||
$profile = $this->createProfile(
|
||||
roles: ['ROLE_VIEWER'],
|
||||
password: 'correct-password',
|
||||
);
|
||||
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('POST', '/_mcp', [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Profile-Id' => $profile->getId(),
|
||||
'X-Profile-Password' => 'wrong-password',
|
||||
],
|
||||
'body' => $this->mcpRequest(),
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testMcpEndpointAcceptsValidCredentials(): void
|
||||
{
|
||||
$profile = $this->createProfile(
|
||||
roles: ['ROLE_VIEWER'],
|
||||
password: 'valid-password',
|
||||
);
|
||||
|
||||
$client = static::createClient();
|
||||
|
||||
$client->request('POST', '/_mcp', [
|
||||
'headers' => [
|
||||
'Content-Type' => 'application/json',
|
||||
'X-Profile-Id' => $profile->getId(),
|
||||
'X-Profile-Password' => 'valid-password',
|
||||
],
|
||||
'body' => $this->mcpRequest(),
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(200);
|
||||
}
|
||||
|
||||
private function mcpRequest(array $headers = [], array $body = []): string
|
||||
{
|
||||
$default = [
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => 'initialize',
|
||||
'params' => [
|
||||
'protocolVersion' => '2025-03-26',
|
||||
'capabilities' => new stdClass(),
|
||||
'clientInfo' => ['name' => 'test', 'version' => '1.0'],
|
||||
],
|
||||
'id' => 1,
|
||||
];
|
||||
|
||||
return json_encode(array_merge($default, $body));
|
||||
}
|
||||
}
|
||||
98
tests/Mcp/Tool/Comment/CommentsToolTest.php
Normal file
98
tests/Mcp/Tool/Comment/CommentsToolTest.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool\Comment;
|
||||
|
||||
use App\Entity\Comment;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CommentsToolTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testListComments(): void
|
||||
{
|
||||
$entityId = 'entity-'.uniqid();
|
||||
$this->createComment('First comment', 'machine', $entityId);
|
||||
$this->createComment('Second comment', 'machine', $entityId);
|
||||
$this->createComment('Other entity', 'machine', 'other-id');
|
||||
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'list_comments', [
|
||||
'entityType' => 'machine',
|
||||
'entityId' => $entityId,
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame(2, $data['_parsed']['total']);
|
||||
$this->assertCount(2, $data['_parsed']['items']);
|
||||
}
|
||||
|
||||
public function testCreateComment(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'create_comment', [
|
||||
'content' => 'A new comment',
|
||||
'entityType' => 'machine',
|
||||
'entityId' => 'some-machine-id',
|
||||
'entityName' => 'Machine Alpha',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertNotEmpty($data['_parsed']['id']);
|
||||
$this->assertSame('A new comment', $data['_parsed']['content']);
|
||||
$this->assertSame('machine', $data['_parsed']['entityType']);
|
||||
$this->assertSame('open', $data['_parsed']['status']);
|
||||
$this->assertSame('Machine Alpha', $data['_parsed']['entityName']);
|
||||
}
|
||||
|
||||
public function testResolveComment(): void
|
||||
{
|
||||
$comment = $this->createComment('To resolve', 'piece', 'piece-123');
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'resolve_comment', [
|
||||
'commentId' => $comment->getId(),
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('resolved', $data['_parsed']['status']);
|
||||
$this->assertNotEmpty($data['_parsed']['resolvedByName']);
|
||||
}
|
||||
|
||||
public function testUnresolvedCount(): void
|
||||
{
|
||||
$entityId = 'entity-'.uniqid();
|
||||
$this->createComment('Open 1', 'machine', $entityId, 'open');
|
||||
$this->createComment('Open 2', 'machine', $entityId, 'open');
|
||||
$this->createComment('Resolved', 'machine', $entityId, 'resolved');
|
||||
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'get_unresolved_comments_count');
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertGreaterThanOrEqual(2, $data['_parsed']['count']);
|
||||
}
|
||||
|
||||
private function createComment(string $content, string $entityType, string $entityId, string $status = 'open'): Comment
|
||||
{
|
||||
$comment = new Comment();
|
||||
$comment->setContent($content);
|
||||
$comment->setEntityType($entityType);
|
||||
$comment->setEntityId($entityId);
|
||||
$comment->setAuthorId('test-author-id');
|
||||
$comment->setAuthorName('Test Author');
|
||||
$comment->setStatus($status);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($comment);
|
||||
$em->flush();
|
||||
|
||||
return $comment;
|
||||
}
|
||||
}
|
||||
99
tests/Mcp/Tool/Composant/ComposantsCrudToolTest.php
Normal file
99
tests/Mcp/Tool/Composant/ComposantsCrudToolTest.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool\Composant;
|
||||
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class ComposantsCrudToolTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testListComposants(): void
|
||||
{
|
||||
$this->createComposant(name: 'Composant Alpha');
|
||||
$this->createComposant(name: 'Composant Beta');
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'list_composants');
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertGreaterThanOrEqual(2, $data['_parsed']['total']);
|
||||
}
|
||||
|
||||
public function testGetComposant(): void
|
||||
{
|
||||
$constructeur = $this->createConstructeur(name: 'Fournisseur Comp');
|
||||
$modelType = $this->createModelType(name: 'Type Composant', code: 'TC-001', category: ModelCategory::COMPONENT);
|
||||
$composant = $this->createComposant(name: 'Composant Gamma', type: $modelType);
|
||||
|
||||
// Add constructeur to composant
|
||||
$composant->addConstructeur($constructeur);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'get_composant', ['composantId' => $composant->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Composant Gamma', $data['_parsed']['name']);
|
||||
$this->assertNotNull($data['_parsed']['typeComposant']);
|
||||
$this->assertSame('Type Composant', $data['_parsed']['typeComposant']['name']);
|
||||
$this->assertCount(1, $data['_parsed']['constructeurs']);
|
||||
$this->assertSame('Fournisseur Comp', $data['_parsed']['constructeurs'][0]['name']);
|
||||
}
|
||||
|
||||
public function testCreateComposant(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'create_composant', [
|
||||
'name' => 'Composant Nouveau',
|
||||
'reference' => 'REF-COMP',
|
||||
'description' => 'Un composant de test',
|
||||
'prix' => '42.99',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Composant Nouveau', $data['_parsed']['name']);
|
||||
$this->assertNotEmpty($data['_parsed']['id']);
|
||||
}
|
||||
|
||||
public function testCreateComposantRequiresGestionnaire(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'create_composant', ['name' => 'Forbidden']);
|
||||
|
||||
$this->assertArrayHasKey('error', $data, 'Should fail with VIEWER role');
|
||||
}
|
||||
|
||||
public function testUpdateComposant(): void
|
||||
{
|
||||
$composant = $this->createComposant(name: 'Old Composant');
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'update_composant', [
|
||||
'composantId' => $composant->getId(),
|
||||
'name' => 'Updated Composant',
|
||||
'prix' => '99.00',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Updated Composant', $data['_parsed']['name']);
|
||||
}
|
||||
|
||||
public function testDeleteComposant(): void
|
||||
{
|
||||
$composant = $this->createComposant(name: 'To Delete');
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'delete_composant', ['composantId' => $composant->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertTrue($data['_parsed']['deleted']);
|
||||
}
|
||||
}
|
||||
85
tests/Mcp/Tool/Constructeur/ConstructeursCrudToolTest.php
Normal file
85
tests/Mcp/Tool/Constructeur/ConstructeursCrudToolTest.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool\Constructeur;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class ConstructeursCrudToolTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testListConstructeurs(): void
|
||||
{
|
||||
$this->createConstructeur(name: 'Constructeur Alpha');
|
||||
$this->createConstructeur(name: 'Constructeur Beta');
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'list_constructeurs');
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data, 'MCP response: '.json_encode($data));
|
||||
$this->assertGreaterThanOrEqual(2, $data['_parsed']['total']);
|
||||
}
|
||||
|
||||
public function testGetConstructeur(): void
|
||||
{
|
||||
$constructeur = $this->createConstructeur(name: 'Constructeur Gamma');
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'get_constructeur', ['constructeurId' => $constructeur->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Constructeur Gamma', $data['_parsed']['name']);
|
||||
}
|
||||
|
||||
public function testCreateConstructeur(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'create_constructeur', [
|
||||
'name' => 'Constructeur Nouveau',
|
||||
'email' => 'contact@nouveau.com',
|
||||
'phone' => '+33123456789',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Constructeur Nouveau', $data['_parsed']['name']);
|
||||
$this->assertNotEmpty($data['_parsed']['id']);
|
||||
}
|
||||
|
||||
public function testCreateConstructeurRequiresGestionnaire(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'create_constructeur', ['name' => 'Forbidden']);
|
||||
|
||||
$this->assertArrayHasKey('error', $data, 'Should fail with VIEWER role');
|
||||
}
|
||||
|
||||
public function testUpdateConstructeur(): void
|
||||
{
|
||||
$constructeur = $this->createConstructeur(name: 'Old Name');
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'update_constructeur', [
|
||||
'constructeurId' => $constructeur->getId(),
|
||||
'name' => 'New Name',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('New Name', $data['_parsed']['name']);
|
||||
}
|
||||
|
||||
public function testDeleteConstructeur(): void
|
||||
{
|
||||
$constructeur = $this->createConstructeur(name: 'To Delete');
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'delete_constructeur', ['constructeurId' => $constructeur->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertTrue($data['_parsed']['deleted']);
|
||||
}
|
||||
}
|
||||
101
tests/Mcp/Tool/CustomField/CustomFieldToolsTest.php
Normal file
101
tests/Mcp/Tool/CustomField/CustomFieldToolsTest.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool\CustomField;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class CustomFieldToolsTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testListCustomFieldValues(): void
|
||||
{
|
||||
$machine = $this->createMachine(name: 'Machine CF');
|
||||
$customField = $this->createCustomField(name: 'Serial Number', type: 'text', machine: $machine);
|
||||
$this->createCustomFieldValue(customField: $customField, value: 'SN-12345', machine: $machine);
|
||||
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'list_custom_field_values', [
|
||||
'entityType' => 'machine',
|
||||
'entityId' => $machine->getId(),
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$parsed = $data['_parsed'];
|
||||
$this->assertSame('machine', $parsed['entityType']);
|
||||
$this->assertSame($machine->getId(), $parsed['entityId']);
|
||||
$this->assertSame(1, $parsed['total']);
|
||||
$this->assertSame('SN-12345', $parsed['values'][0]['value']);
|
||||
$this->assertSame('Serial Number', $parsed['values'][0]['customFieldName']);
|
||||
$this->assertSame('text', $parsed['values'][0]['customFieldType']);
|
||||
}
|
||||
|
||||
public function testUpsertCustomFieldValues(): void
|
||||
{
|
||||
$machine = $this->createMachine(name: 'Machine Upsert');
|
||||
$customField = $this->createCustomField(name: 'Voltage', type: 'text', machine: $machine);
|
||||
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
// Create
|
||||
$data = $this->callMcpTool($session, 'upsert_custom_field_values', [
|
||||
'entityType' => 'machine',
|
||||
'entityId' => $machine->getId(),
|
||||
'fields' => [
|
||||
['customFieldId' => $customField->getId(), 'value' => '220V'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$parsed = $data['_parsed'];
|
||||
$this->assertSame(1, $parsed['total']);
|
||||
$this->assertSame('created', $parsed['results'][0]['action']);
|
||||
$this->assertSame('220V', $parsed['results'][0]['value']);
|
||||
|
||||
$createdId = $parsed['results'][0]['id'];
|
||||
|
||||
// Update (upsert same field)
|
||||
$data = $this->callMcpTool($session, 'upsert_custom_field_values', [
|
||||
'entityType' => 'machine',
|
||||
'entityId' => $machine->getId(),
|
||||
'fields' => [
|
||||
['customFieldId' => $customField->getId(), 'value' => '380V'],
|
||||
],
|
||||
]);
|
||||
|
||||
$parsed = $data['_parsed'];
|
||||
$this->assertSame('updated', $parsed['results'][0]['action']);
|
||||
$this->assertSame('380V', $parsed['results'][0]['value']);
|
||||
$this->assertSame($createdId, $parsed['results'][0]['id']);
|
||||
}
|
||||
|
||||
public function testDeleteCustomFieldValue(): void
|
||||
{
|
||||
$machine = $this->createMachine(name: 'Machine Delete CF');
|
||||
$customField = $this->createCustomField(name: 'Weight', type: 'text', machine: $machine);
|
||||
$cfv = $this->createCustomFieldValue(customField: $customField, value: '150kg', machine: $machine);
|
||||
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'delete_custom_field_value', [
|
||||
'customFieldValueId' => $cfv->getId(),
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$parsed = $data['_parsed'];
|
||||
$this->assertTrue($parsed['deleted']);
|
||||
$this->assertSame($cfv->getId(), $parsed['id']);
|
||||
|
||||
// Verify it's gone
|
||||
$listData = $this->callMcpTool($session, 'list_custom_field_values', [
|
||||
'entityType' => 'machine',
|
||||
'entityId' => $machine->getId(),
|
||||
]);
|
||||
|
||||
$this->assertSame(0, $listData['_parsed']['total']);
|
||||
}
|
||||
}
|
||||
29
tests/Mcp/Tool/DashboardStatsToolTest.php
Normal file
29
tests/Mcp/Tool/DashboardStatsToolTest.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class DashboardStatsToolTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testGetDashboardStatsReturnsCounters(): void
|
||||
{
|
||||
$site = $this->createSite();
|
||||
$this->createMachine(name: 'Machine Stats 1', site: $site);
|
||||
$this->createMachine(name: 'Machine Stats 2', site: $site);
|
||||
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
$data = $this->callMcpTool($session, 'get_dashboard_stats');
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$stats = $data['_parsed'];
|
||||
$this->assertGreaterThanOrEqual(2, $stats['machines']);
|
||||
$this->assertArrayHasKey('sites', $stats);
|
||||
$this->assertArrayHasKey('unresolvedComments', $stats);
|
||||
}
|
||||
}
|
||||
41
tests/Mcp/Tool/Document/DocumentToolsTest.php
Normal file
41
tests/Mcp/Tool/Document/DocumentToolsTest.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool\Document;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class DocumentToolsTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testListDocuments(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'Doc Site');
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'list_documents', [
|
||||
'entityType' => 'site',
|
||||
'entityId' => $site->getId(),
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('site', $data['_parsed']['entityType']);
|
||||
$this->assertSame($site->getId(), $data['_parsed']['entityId']);
|
||||
$this->assertIsArray($data['_parsed']['items']);
|
||||
$this->assertSame(0, $data['_parsed']['total']);
|
||||
}
|
||||
|
||||
public function testDeleteDocumentRequiresGestionnaire(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'delete_document', [
|
||||
'documentId' => 'nonexistent-id',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('error', $data);
|
||||
}
|
||||
}
|
||||
44
tests/Mcp/Tool/HistoryToolsTest.php
Normal file
44
tests/Mcp/Tool/HistoryToolsTest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class HistoryToolsTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testGetActivityLog(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'get_activity_log');
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertArrayHasKey('items', $data['_parsed']);
|
||||
$this->assertArrayHasKey('total', $data['_parsed']);
|
||||
$this->assertArrayHasKey('page', $data['_parsed']);
|
||||
$this->assertArrayHasKey('limit', $data['_parsed']);
|
||||
$this->assertArrayHasKey('pageCount', $data['_parsed']);
|
||||
$this->assertIsArray($data['_parsed']['items']);
|
||||
}
|
||||
|
||||
public function testGetEntityHistory(): void
|
||||
{
|
||||
$machine = $this->createMachine(name: 'History Machine');
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'get_entity_history', [
|
||||
'entityType' => 'machine',
|
||||
'entityId' => $machine->getId(),
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertArrayHasKey('items', $data['_parsed']);
|
||||
$this->assertArrayHasKey('total', $data['_parsed']);
|
||||
$this->assertIsArray($data['_parsed']['items']);
|
||||
}
|
||||
}
|
||||
81
tests/Mcp/Tool/Machine/MachineLinksToolTest.php
Normal file
81
tests/Mcp/Tool/Machine/MachineLinksToolTest.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool\Machine;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MachineLinksToolTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testListMachineLinks(): void
|
||||
{
|
||||
$machine = $this->createMachine(name: 'Machine Links Test');
|
||||
$composant = $this->createComposant(name: 'Comp A');
|
||||
$piece = $this->createPiece(name: 'Piece A');
|
||||
|
||||
$compLink = $this->createMachineComponentLink($machine, $composant);
|
||||
$pieceLink = $this->createMachinePieceLink($machine, $piece, parentLink: $compLink, quantity: 3);
|
||||
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
$data = $this->callMcpTool($session, 'list_machine_links', ['machineId' => $machine->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$parsed = $data['_parsed'];
|
||||
|
||||
$this->assertSame($machine->getId(), $parsed['machineId']);
|
||||
$this->assertCount(1, $parsed['componentLinks']);
|
||||
$this->assertCount(1, $parsed['pieceLinks']);
|
||||
$this->assertSame($compLink->getId(), $parsed['componentLinks'][0]['id']);
|
||||
$this->assertSame($pieceLink->getId(), $parsed['pieceLinks'][0]['id']);
|
||||
$this->assertSame(3, $parsed['pieceLinks'][0]['quantity']);
|
||||
}
|
||||
|
||||
public function testAddMachineComponentLink(): void
|
||||
{
|
||||
$machine = $this->createMachine(name: 'Machine Add Test');
|
||||
$composant = $this->createComposant(name: 'Comp B');
|
||||
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
$data = $this->callMcpTool($session, 'add_machine_links', [
|
||||
'machineId' => $machine->getId(),
|
||||
'links' => [
|
||||
[
|
||||
'type' => 'composant',
|
||||
'entityId' => $composant->getId(),
|
||||
'nameOverride' => 'Custom Name',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$created = $data['_parsed']['created'];
|
||||
$this->assertCount(1, $created);
|
||||
$this->assertSame('composant', $created[0]['type']);
|
||||
$this->assertSame($composant->getId(), $created[0]['entityId']);
|
||||
$this->assertNotEmpty($created[0]['id']);
|
||||
}
|
||||
|
||||
public function testRemoveMachineLink(): void
|
||||
{
|
||||
$machine = $this->createMachine(name: 'Machine Remove Test');
|
||||
$composant = $this->createComposant(name: 'Comp C');
|
||||
$link = $this->createMachineComponentLink($machine, $composant);
|
||||
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
$data = $this->callMcpTool($session, 'remove_machine_link', [
|
||||
'linkId' => $link->getId(),
|
||||
'linkType' => 'composant',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertTrue($data['_parsed']['deleted']);
|
||||
|
||||
// Verify the link is gone by listing
|
||||
$listData = $this->callMcpTool($session, 'list_machine_links', ['machineId' => $machine->getId()]);
|
||||
$this->assertCount(0, $listData['_parsed']['componentLinks']);
|
||||
}
|
||||
}
|
||||
164
tests/Mcp/Tool/Machine/MachineStructureToolTest.php
Normal file
164
tests/Mcp/Tool/Machine/MachineStructureToolTest.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool\Machine;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MachineStructureToolTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testGetMachineStructure(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'Site Structure');
|
||||
$machine = $this->createMachine(name: 'Machine Structure', site: $site, reference: 'REF-STRUCT');
|
||||
$composant = $this->createComposant(name: 'Composant Alpha');
|
||||
$piece = $this->createPiece(name: 'Piece Alpha', reference: 'REF-P1');
|
||||
$product = $this->createProduct(name: 'Product Alpha', reference: 'REF-PR1');
|
||||
|
||||
$componentLink = $this->createMachineComponentLink($machine, $composant);
|
||||
$this->createMachinePieceLink($machine, $piece, $componentLink, quantity: 3);
|
||||
$this->createMachineProductLink($machine, $product);
|
||||
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'get_machine_structure', [
|
||||
'machineId' => $machine->getId(),
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$parsed = $data['_parsed'];
|
||||
|
||||
// Machine info
|
||||
$this->assertSame('Machine Structure', $parsed['machine']['name']);
|
||||
$this->assertSame('REF-STRUCT', $parsed['machine']['reference']);
|
||||
$this->assertSame($site->getId(), $parsed['machine']['siteId']);
|
||||
|
||||
// Component links
|
||||
$this->assertCount(1, $parsed['componentLinks']);
|
||||
$this->assertSame($composant->getId(), $parsed['componentLinks'][0]['composantId']);
|
||||
$this->assertSame('Composant Alpha', $parsed['componentLinks'][0]['composant']['name']);
|
||||
|
||||
// Piece links
|
||||
$this->assertCount(1, $parsed['pieceLinks']);
|
||||
$this->assertSame($piece->getId(), $parsed['pieceLinks'][0]['pieceId']);
|
||||
$this->assertSame('Piece Alpha', $parsed['pieceLinks'][0]['piece']['name']);
|
||||
$this->assertSame(3, $parsed['pieceLinks'][0]['quantity']);
|
||||
|
||||
// Product links
|
||||
$this->assertCount(1, $parsed['productLinks']);
|
||||
$this->assertSame($product->getId(), $parsed['productLinks'][0]['productId']);
|
||||
$this->assertSame('Product Alpha', $parsed['productLinks'][0]['product']['name']);
|
||||
}
|
||||
|
||||
public function testGetMachineStructureNotFound(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'get_machine_structure', [
|
||||
'machineId' => 'nonexistent-id',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('error', $data);
|
||||
}
|
||||
|
||||
public function testGetMachineStructureWithOverrides(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'Site Overrides');
|
||||
$machine = $this->createMachine(name: 'Machine Overrides', site: $site);
|
||||
$composant = $this->createComposant(name: 'Composant Override');
|
||||
|
||||
$componentLink = $this->createMachineComponentLink($machine, $composant);
|
||||
$componentLink->setNameOverride('Custom Name');
|
||||
$componentLink->setReferenceOverride('CUSTOM-REF');
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'get_machine_structure', [
|
||||
'machineId' => $machine->getId(),
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$overrides = $data['_parsed']['componentLinks'][0]['overrides'];
|
||||
$this->assertSame('Custom Name', $overrides['name']);
|
||||
$this->assertSame('CUSTOM-REF', $overrides['reference']);
|
||||
}
|
||||
|
||||
public function testCloneMachine(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'Site Source');
|
||||
$targetSite = $this->createSite(name: 'Site Target');
|
||||
$machine = $this->createMachine(name: 'Machine Source', site: $site, reference: 'REF-SRC');
|
||||
$composant = $this->createComposant(name: 'Composant Clone');
|
||||
$piece = $this->createPiece(name: 'Piece Clone');
|
||||
$product = $this->createProduct(name: 'Product Clone');
|
||||
|
||||
$componentLink = $this->createMachineComponentLink($machine, $composant);
|
||||
$this->createMachinePieceLink($machine, $piece, $componentLink, quantity: 2);
|
||||
$this->createMachineProductLink($machine, $product);
|
||||
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'clone_machine', [
|
||||
'machineId' => $machine->getId(),
|
||||
'name' => 'Machine Cloned',
|
||||
'siteId' => $targetSite->getId(),
|
||||
'reference' => 'REF-CLN',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$parsed = $data['_parsed'];
|
||||
|
||||
$this->assertSame('Machine Cloned', $parsed['name']);
|
||||
$this->assertSame('REF-CLN', $parsed['reference']);
|
||||
$this->assertSame($targetSite->getId(), $parsed['siteId']);
|
||||
$this->assertSame($machine->getId(), $parsed['clonedFrom']);
|
||||
$this->assertNotSame($machine->getId(), $parsed['id']);
|
||||
|
||||
// Verify the cloned machine has links by fetching its structure
|
||||
$structureData = $this->callMcpTool($session, 'get_machine_structure', [
|
||||
'machineId' => $parsed['id'],
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $structureData);
|
||||
$structure = $structureData['_parsed'];
|
||||
|
||||
$this->assertCount(1, $structure['componentLinks']);
|
||||
$this->assertSame($composant->getId(), $structure['componentLinks'][0]['composantId']);
|
||||
$this->assertCount(1, $structure['pieceLinks']);
|
||||
$this->assertCount(1, $structure['productLinks']);
|
||||
}
|
||||
|
||||
public function testCloneMachineRequiresGestionnaire(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'Site Forbidden');
|
||||
$machine = $this->createMachine(name: 'Machine Forbidden', site: $site);
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'clone_machine', [
|
||||
'machineId' => $machine->getId(),
|
||||
'name' => 'Should Fail',
|
||||
'siteId' => $site->getId(),
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('error', $data, 'Should fail with VIEWER role');
|
||||
}
|
||||
|
||||
public function testCloneMachineNotFound(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'Site Clone NF');
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'clone_machine', [
|
||||
'machineId' => 'nonexistent-id',
|
||||
'name' => 'Should Fail',
|
||||
'siteId' => $site->getId(),
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('error', $data);
|
||||
}
|
||||
}
|
||||
107
tests/Mcp/Tool/Machine/MachinesCrudToolTest.php
Normal file
107
tests/Mcp/Tool/Machine/MachinesCrudToolTest.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool\Machine;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class MachinesCrudToolTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testListMachines(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'Site Usine');
|
||||
$this->createMachine(name: 'Machine Alpha', site: $site);
|
||||
$this->createMachine(name: 'Machine Beta', site: $site);
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'list_machines');
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertGreaterThanOrEqual(2, $data['_parsed']['total']);
|
||||
}
|
||||
|
||||
public function testGetMachine(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'Site Principal');
|
||||
$constructeur = $this->createConstructeur(name: 'Fournisseur M');
|
||||
$machine = $this->createMachine(name: 'Machine Gamma', site: $site, reference: 'REF-M001');
|
||||
|
||||
// Add constructeur to machine
|
||||
$machine->addConstructeur($constructeur);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'get_machine', ['machineId' => $machine->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Machine Gamma', $data['_parsed']['name']);
|
||||
$this->assertSame('REF-M001', $data['_parsed']['reference']);
|
||||
$this->assertNotNull($data['_parsed']['site']);
|
||||
$this->assertSame('Site Principal', $data['_parsed']['site']['name']);
|
||||
$this->assertCount(1, $data['_parsed']['constructeurs']);
|
||||
$this->assertSame('Fournisseur M', $data['_parsed']['constructeurs'][0]['name']);
|
||||
}
|
||||
|
||||
public function testCreateMachine(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'Site Création');
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'create_machine', [
|
||||
'name' => 'Machine Nouvelle',
|
||||
'siteId' => $site->getId(),
|
||||
'reference' => 'REF-NEW',
|
||||
'prix' => '42.99',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Machine Nouvelle', $data['_parsed']['name']);
|
||||
$this->assertNotEmpty($data['_parsed']['id']);
|
||||
}
|
||||
|
||||
public function testCreateMachineRequiresGestionnaire(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'Site Forbidden');
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'create_machine', [
|
||||
'name' => 'Forbidden',
|
||||
'siteId' => $site->getId(),
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('error', $data, 'Should fail with VIEWER role');
|
||||
}
|
||||
|
||||
public function testUpdateMachine(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'Site Update');
|
||||
$machine = $this->createMachine(name: 'Old Machine', site: $site);
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'update_machine', [
|
||||
'machineId' => $machine->getId(),
|
||||
'name' => 'Updated Machine',
|
||||
'prix' => '99.00',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Updated Machine', $data['_parsed']['name']);
|
||||
}
|
||||
|
||||
public function testDeleteMachine(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'Site Delete');
|
||||
$machine = $this->createMachine(name: 'To Delete', site: $site);
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'delete_machine', ['machineId' => $machine->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertTrue($data['_parsed']['deleted']);
|
||||
}
|
||||
}
|
||||
66
tests/Mcp/Tool/ModelType/ModelTypeToolsTest.php
Normal file
66
tests/Mcp/Tool/ModelType/ModelTypeToolsTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool\ModelType;
|
||||
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class ModelTypeToolsTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testListModelTypes(): void
|
||||
{
|
||||
$this->createModelType(name: 'MT Alpha', code: 'MTA-'.bin2hex(random_bytes(3)), category: ModelCategory::COMPONENT);
|
||||
$this->createModelType(name: 'MT Beta', code: 'MTB-'.bin2hex(random_bytes(3)), category: ModelCategory::PIECE);
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'list_model_types');
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertGreaterThanOrEqual(2, $data['_parsed']['total']);
|
||||
}
|
||||
|
||||
public function testGetModelType(): void
|
||||
{
|
||||
$mt = $this->createModelType(name: 'MT Detail', code: 'MTD-'.bin2hex(random_bytes(3)));
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'get_model_type', ['modelTypeId' => $mt->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('MT Detail', $data['_parsed']['name']);
|
||||
$this->assertSame('COMPONENT', $data['_parsed']['category']);
|
||||
$this->assertIsArray($data['_parsed']['skeletonPieceRequirements']);
|
||||
}
|
||||
|
||||
public function testCreateModelType(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'create_model_type', [
|
||||
'name' => 'MT Nouveau',
|
||||
'category' => 'composant',
|
||||
'code' => 'MTN-'.bin2hex(random_bytes(3)),
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('MT Nouveau', $data['_parsed']['name']);
|
||||
$this->assertSame('COMPONENT', $data['_parsed']['category']);
|
||||
$this->assertNotEmpty($data['_parsed']['id']);
|
||||
}
|
||||
|
||||
public function testDeleteModelType(): void
|
||||
{
|
||||
$mt = $this->createModelType(name: 'MT To Delete', code: 'MTDEL-'.bin2hex(random_bytes(3)));
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'delete_model_type', ['modelTypeId' => $mt->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertTrue($data['_parsed']['deleted']);
|
||||
}
|
||||
}
|
||||
99
tests/Mcp/Tool/Piece/PiecesCrudToolTest.php
Normal file
99
tests/Mcp/Tool/Piece/PiecesCrudToolTest.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool\Piece;
|
||||
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class PiecesCrudToolTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testListPieces(): void
|
||||
{
|
||||
$this->createPiece(name: 'Piece Alpha');
|
||||
$this->createPiece(name: 'Piece Beta', reference: 'REF-BETA');
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'list_pieces');
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertGreaterThanOrEqual(2, $data['_parsed']['total']);
|
||||
}
|
||||
|
||||
public function testGetPiece(): void
|
||||
{
|
||||
$constructeur = $this->createConstructeur(name: 'Fournisseur Piece');
|
||||
$modelType = $this->createModelType(name: 'Type Piece', code: 'TP-001', category: ModelCategory::PIECE);
|
||||
$piece = $this->createPiece(name: 'Piece Gamma', reference: 'REF-001', type: $modelType);
|
||||
|
||||
// Add constructeur to piece
|
||||
$piece->addConstructeur($constructeur);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'get_piece', ['pieceId' => $piece->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Piece Gamma', $data['_parsed']['name']);
|
||||
$this->assertSame('REF-001', $data['_parsed']['reference']);
|
||||
$this->assertNotNull($data['_parsed']['typePiece']);
|
||||
$this->assertSame('Type Piece', $data['_parsed']['typePiece']['name']);
|
||||
$this->assertCount(1, $data['_parsed']['constructeurs']);
|
||||
$this->assertSame('Fournisseur Piece', $data['_parsed']['constructeurs'][0]['name']);
|
||||
}
|
||||
|
||||
public function testCreatePiece(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'create_piece', [
|
||||
'name' => 'Piece Nouveau',
|
||||
'reference' => 'REF-NEW',
|
||||
'prix' => '42.99',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Piece Nouveau', $data['_parsed']['name']);
|
||||
$this->assertNotEmpty($data['_parsed']['id']);
|
||||
}
|
||||
|
||||
public function testCreatePieceRequiresGestionnaire(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'create_piece', ['name' => 'Forbidden']);
|
||||
|
||||
$this->assertArrayHasKey('error', $data, 'Should fail with VIEWER role');
|
||||
}
|
||||
|
||||
public function testUpdatePiece(): void
|
||||
{
|
||||
$piece = $this->createPiece(name: 'Old Piece');
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'update_piece', [
|
||||
'pieceId' => $piece->getId(),
|
||||
'name' => 'Updated Piece',
|
||||
'prix' => '99.00',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Updated Piece', $data['_parsed']['name']);
|
||||
}
|
||||
|
||||
public function testDeletePiece(): void
|
||||
{
|
||||
$piece = $this->createPiece(name: 'To Delete');
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'delete_piece', ['pieceId' => $piece->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertTrue($data['_parsed']['deleted']);
|
||||
}
|
||||
}
|
||||
99
tests/Mcp/Tool/Product/ProductsCrudToolTest.php
Normal file
99
tests/Mcp/Tool/Product/ProductsCrudToolTest.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool\Product;
|
||||
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class ProductsCrudToolTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testListProducts(): void
|
||||
{
|
||||
$this->createProduct(name: 'Product Alpha');
|
||||
$this->createProduct(name: 'Product Beta');
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'list_products');
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertGreaterThanOrEqual(2, $data['_parsed']['total']);
|
||||
}
|
||||
|
||||
public function testGetProduct(): void
|
||||
{
|
||||
$constructeur = $this->createConstructeur(name: 'Fournisseur A');
|
||||
$modelType = $this->createModelType(name: 'Type Produit', code: 'TP-001', category: ModelCategory::PRODUCT);
|
||||
$product = $this->createProduct(name: 'Product Gamma', reference: 'REF-001', type: $modelType);
|
||||
|
||||
// Add constructeur to product
|
||||
$product->addConstructeur($constructeur);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'get_product', ['productId' => $product->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Product Gamma', $data['_parsed']['name']);
|
||||
$this->assertSame('REF-001', $data['_parsed']['reference']);
|
||||
$this->assertNotNull($data['_parsed']['typeProduct']);
|
||||
$this->assertSame('Type Produit', $data['_parsed']['typeProduct']['name']);
|
||||
$this->assertCount(1, $data['_parsed']['constructeurs']);
|
||||
$this->assertSame('Fournisseur A', $data['_parsed']['constructeurs'][0]['name']);
|
||||
}
|
||||
|
||||
public function testCreateProduct(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'create_product', [
|
||||
'name' => 'Product Nouveau',
|
||||
'reference' => 'REF-NEW',
|
||||
'supplierPrice' => '42.99',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Product Nouveau', $data['_parsed']['name']);
|
||||
$this->assertNotEmpty($data['_parsed']['id']);
|
||||
}
|
||||
|
||||
public function testCreateProductRequiresGestionnaire(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'create_product', ['name' => 'Forbidden']);
|
||||
|
||||
$this->assertArrayHasKey('error', $data, 'Should fail with VIEWER role');
|
||||
}
|
||||
|
||||
public function testUpdateProduct(): void
|
||||
{
|
||||
$product = $this->createProduct(name: 'Old Product');
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'update_product', [
|
||||
'productId' => $product->getId(),
|
||||
'name' => 'Updated Product',
|
||||
'supplierPrice' => '99.00',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Updated Product', $data['_parsed']['name']);
|
||||
}
|
||||
|
||||
public function testDeleteProduct(): void
|
||||
{
|
||||
$product = $this->createProduct(name: 'To Delete');
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'delete_product', ['productId' => $product->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertTrue($data['_parsed']['deleted']);
|
||||
}
|
||||
}
|
||||
63
tests/Mcp/Tool/SearchInventoryToolTest.php
Normal file
63
tests/Mcp/Tool/SearchInventoryToolTest.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class SearchInventoryToolTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testSearchFindsAcrossEntities(): void
|
||||
{
|
||||
$this->createMachine(name: 'Alpha Machine');
|
||||
$this->createPiece(name: 'Alpha Piece');
|
||||
$this->createSite(name: 'Alpha Site');
|
||||
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'search_inventory', ['query' => 'Alpha']);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$results = $data['_parsed'];
|
||||
$this->assertIsArray($results);
|
||||
|
||||
$types = array_unique(array_column($results, 'type'));
|
||||
$this->assertContains('machine', $types);
|
||||
$this->assertContains('piece', $types);
|
||||
$this->assertContains('site', $types);
|
||||
|
||||
foreach ($results as $result) {
|
||||
$this->assertArrayHasKey('type', $result);
|
||||
$this->assertArrayHasKey('id', $result);
|
||||
$this->assertArrayHasKey('name', $result);
|
||||
$this->assertArrayHasKey('reference', $result);
|
||||
$this->assertStringContainsStringIgnoringCase('Alpha', $result['name']);
|
||||
}
|
||||
}
|
||||
|
||||
public function testSearchFiltersByType(): void
|
||||
{
|
||||
$this->createMachine(name: 'Beta Machine');
|
||||
$this->createPiece(name: 'Beta Piece');
|
||||
$this->createSite(name: 'Beta Site');
|
||||
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'search_inventory', [
|
||||
'query' => 'Beta',
|
||||
'types' => 'machine',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$results = $data['_parsed'];
|
||||
$this->assertIsArray($results);
|
||||
$this->assertNotEmpty($results);
|
||||
|
||||
$types = array_unique(array_column($results, 'type'));
|
||||
$this->assertSame(['machine'], $types);
|
||||
}
|
||||
}
|
||||
84
tests/Mcp/Tool/Site/SitesCrudToolTest.php
Normal file
84
tests/Mcp/Tool/Site/SitesCrudToolTest.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Mcp\Tool\Site;
|
||||
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class SitesCrudToolTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testListSites(): void
|
||||
{
|
||||
$this->createSite(name: 'Site Alpha');
|
||||
$this->createSite(name: 'Site Beta');
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'list_sites');
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertGreaterThanOrEqual(2, $data['_parsed']['total']);
|
||||
}
|
||||
|
||||
public function testGetSite(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'Site Gamma');
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'get_site', ['siteId' => $site->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Site Gamma', $data['_parsed']['name']);
|
||||
}
|
||||
|
||||
public function testCreateSite(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'create_site', [
|
||||
'name' => 'Site Nouveau',
|
||||
'contactCity' => 'Paris',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('Site Nouveau', $data['_parsed']['name']);
|
||||
$this->assertNotEmpty($data['_parsed']['id']);
|
||||
}
|
||||
|
||||
public function testCreateSiteRequiresGestionnaire(): void
|
||||
{
|
||||
$session = $this->createMcpClient('ROLE_VIEWER');
|
||||
|
||||
$data = $this->callMcpTool($session, 'create_site', ['name' => 'Forbidden']);
|
||||
|
||||
$this->assertArrayHasKey('error', $data);
|
||||
}
|
||||
|
||||
public function testUpdateSite(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'Old Name');
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'update_site', [
|
||||
'siteId' => $site->getId(),
|
||||
'name' => 'New Name',
|
||||
]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertSame('New Name', $data['_parsed']['name']);
|
||||
}
|
||||
|
||||
public function testDeleteSite(): void
|
||||
{
|
||||
$site = $this->createSite(name: 'To Delete');
|
||||
$session = $this->createMcpClient('ROLE_GESTIONNAIRE');
|
||||
|
||||
$data = $this->callMcpTool($session, 'delete_site', ['siteId' => $site->getId()]);
|
||||
|
||||
$this->assertArrayHasKey('_parsed', $data);
|
||||
$this->assertTrue($data['_parsed']['deleted']);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user