Compare commits
8 Commits
feat/json-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65fbd38b55 | ||
|
|
37aa755819 | ||
|
|
98caaa148d | ||
|
|
523eed927e | ||
|
|
43bec07bb8 | ||
|
|
0181f18778 | ||
|
|
8e0acf4896 | ||
|
|
aa8e043c83 |
Submodule Inventory_frontend updated: 271844efb1...d4fc0f1fee
@@ -14,6 +14,7 @@
|
|||||||
"doctrine/orm": "^3.6",
|
"doctrine/orm": "^3.6",
|
||||||
"lexik/jwt-authentication-bundle": "^3.2",
|
"lexik/jwt-authentication-bundle": "^3.2",
|
||||||
"nelmio/cors-bundle": "^2.6",
|
"nelmio/cors-bundle": "^2.6",
|
||||||
|
"nyholm/psr7": "^1.8",
|
||||||
"phpdocumentor/reflection-docblock": "^5.6",
|
"phpdocumentor/reflection-docblock": "^5.6",
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
"phpstan/phpdoc-parser": "^2.3",
|
||||||
"symfony/asset": "8.0.*",
|
"symfony/asset": "8.0.*",
|
||||||
@@ -22,8 +23,10 @@
|
|||||||
"symfony/expression-language": "8.0.*",
|
"symfony/expression-language": "8.0.*",
|
||||||
"symfony/flex": "^2",
|
"symfony/flex": "^2",
|
||||||
"symfony/framework-bundle": "8.0.*",
|
"symfony/framework-bundle": "8.0.*",
|
||||||
|
"symfony/mcp-bundle": "^0.6.0",
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "8.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
|
"symfony/rate-limiter": "8.0.*",
|
||||||
"symfony/runtime": "8.0.*",
|
"symfony/runtime": "8.0.*",
|
||||||
"symfony/security-bundle": "8.0.*",
|
"symfony/security-bundle": "8.0.*",
|
||||||
"symfony/serializer": "8.0.*",
|
"symfony/serializer": "8.0.*",
|
||||||
|
|||||||
1033
composer.lock
generated
1033
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
api_platform:
|
api_platform:
|
||||||
title: Inventory API
|
title: Inventory API
|
||||||
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
||||||
version: 1.8.1
|
version: 1.9.1
|
||||||
defaults:
|
defaults:
|
||||||
stateless: false
|
stateless: false
|
||||||
cache_headers:
|
cache_headers:
|
||||||
|
|||||||
10
config/packages/http_discovery.yaml
Normal file
10
config/packages/http_discovery.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
|
||||||
|
http_discovery.psr17_factory:
|
||||||
|
class: Http\Discovery\Psr17Factory
|
||||||
20
config/packages/mcp.yaml.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.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,13 @@ security:
|
|||||||
pattern: ^/api/session/profiles?$
|
pattern: ^/api/session/profiles?$
|
||||||
security: false
|
security: false
|
||||||
|
|
||||||
|
# TODO: re-enable when symfony/ai-mcp-bundle is installed
|
||||||
|
# mcp:
|
||||||
|
# pattern: ^/_mcp
|
||||||
|
# stateless: true
|
||||||
|
# custom_authenticators:
|
||||||
|
# - App\Mcp\Security\McpHeaderAuthenticator
|
||||||
|
|
||||||
api:
|
api:
|
||||||
pattern: ^/api
|
pattern: ^/api
|
||||||
stateless: false
|
stateless: false
|
||||||
@@ -49,6 +56,7 @@ security:
|
|||||||
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
||||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
||||||
|
# - { path: ^/_mcp, roles: ROLE_USER } # TODO: re-enable with MCP
|
||||||
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
||||||
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
|
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ services:
|
|||||||
# this creates a service per class whose id is the fully-qualified class name
|
# this creates a service per class whose id is the fully-qualified class name
|
||||||
App\:
|
App\:
|
||||||
resource: '../src/'
|
resource: '../src/'
|
||||||
|
exclude:
|
||||||
|
- '../src/Mcp/'
|
||||||
|
|
||||||
# add more service definitions when explicit configuration is needed
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# please note that last definitions always *replace* previous ones
|
||||||
@@ -34,6 +36,11 @@ services:
|
|||||||
tags:
|
tags:
|
||||||
- { name: doctrine.event_subscriber }
|
- { name: doctrine.event_subscriber }
|
||||||
|
|
||||||
|
# TODO: re-enable when symfony/ai-mcp-bundle is installed
|
||||||
|
# App\Mcp\Security\McpHeaderAuthenticator:
|
||||||
|
# arguments:
|
||||||
|
# $mcpAuthLimiter: '@limiter.mcp_auth'
|
||||||
|
|
||||||
App\OpenApi\OpenApiDecorator:
|
App\OpenApi\OpenApiDecorator:
|
||||||
decorates: 'api_platform.openapi.factory'
|
decorates: 'api_platform.openapi.factory'
|
||||||
arguments:
|
arguments:
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<testsuites>
|
<testsuites>
|
||||||
<testsuite name="Project Test Suite">
|
<testsuite name="Project Test Suite">
|
||||||
<directory>tests</directory>
|
<directory>tests</directory>
|
||||||
|
<exclude>tests/Mcp</exclude>
|
||||||
</testsuite>
|
</testsuite>
|
||||||
</testsuites>
|
</testsuites>
|
||||||
|
|
||||||
|
|||||||
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,55 +18,158 @@ class SkeletonStructureService
|
|||||||
|
|
||||||
public function updateSkeletonRequirements(ModelType $modelType, array $structure): void
|
public function updateSkeletonRequirements(ModelType $modelType, array $structure): void
|
||||||
{
|
{
|
||||||
// Clear existing requirements
|
// Update piece requirements in-place (match by typeId, then update position)
|
||||||
foreach ($modelType->getSkeletonPieceRequirements() as $req) {
|
$this->syncPieceRequirements($modelType, $structure['pieces'] ?? []);
|
||||||
$modelType->removeSkeletonPieceRequirement($req);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($modelType->getSkeletonProductRequirements() as $req) {
|
// Update product requirements in-place
|
||||||
$modelType->removeSkeletonProductRequirement($req);
|
$this->syncProductRequirements($modelType, $structure['products'] ?? []);
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($modelType->getSkeletonSubcomponentRequirements() as $req) {
|
// Update subcomponent requirements in-place
|
||||||
$modelType->removeSkeletonSubcomponentRequirement($req);
|
$this->syncSubcomponentRequirements($modelType, $structure['subcomponents'] ?? []);
|
||||||
}
|
|
||||||
|
|
||||||
// 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 custom field definitions
|
// Update custom field definitions
|
||||||
$this->updateCustomFields($modelType, $structure['customFields'] ?? []);
|
$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.
|
* Sync CustomField entities for this ModelType.
|
||||||
* Handles two frontend formats:
|
* Handles two frontend formats:
|
||||||
|
|||||||
@@ -51,23 +51,20 @@ class ComposantSyncStrategy implements SyncStrategyInterface
|
|||||||
$addedCfValues = 0;
|
$addedCfValues = 0;
|
||||||
$deletedCfValues = 0;
|
$deletedCfValues = 0;
|
||||||
|
|
||||||
// Map proposed by (typeId, position) keys — position defaults to array index
|
// Build proposed typeId lists (one entry per requirement, order = position)
|
||||||
$proposedPieceKeys = [];
|
$proposedPieceTypeIds = [];
|
||||||
foreach ($proposedPieces as $i => $pp) {
|
foreach ($proposedPieces as $pp) {
|
||||||
$pos = $pp['position'] ?? $i;
|
$proposedPieceTypeIds[] = $pp['typePieceId'];
|
||||||
$proposedPieceKeys[$pp['typePieceId'].'|'.$pos] = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$proposedProductKeys = [];
|
$proposedProductTypeIds = [];
|
||||||
foreach ($proposedProducts as $i => $pp) {
|
foreach ($proposedProducts as $pp) {
|
||||||
$pos = $pp['position'] ?? $i;
|
$proposedProductTypeIds[] = $pp['typeProductId'];
|
||||||
$proposedProductKeys[$pp['typeProductId'].'|'.$pos] = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$proposedSubKeys = [];
|
$proposedSubTypeIds = [];
|
||||||
foreach ($proposedSubcomponents as $i => $ps) {
|
foreach ($proposedSubcomponents as $ps) {
|
||||||
$pos = $ps['position'] ?? $i;
|
$proposedSubTypeIds[] = $ps['typeComposantId'];
|
||||||
$proposedSubKeys[$ps['typeComposantId'].'|'.$pos] = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map proposed custom fields by orderIndex (falls back to array index)
|
// Map proposed custom fields by orderIndex (falls back to array index)
|
||||||
@@ -102,59 +99,26 @@ class ComposantSyncStrategy implements SyncStrategyInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($composants as $composant) {
|
foreach ($composants as $composant) {
|
||||||
// Piece slots — query from repository to avoid stale collection
|
// Piece slots
|
||||||
$pieceSlots = $this->em->getRepository(ComposantPieceSlot::class)->findBy(['composant' => $composant]);
|
$pieceSlots = $this->em->getRepository(ComposantPieceSlot::class)->findBy(['composant' => $composant]);
|
||||||
$existingPieceKeys = [];
|
$existingPieceTypes = array_map(fn (ComposantPieceSlot $s) => $s->getTypePiece()?->getId() ?? '', $pieceSlots);
|
||||||
foreach ($pieceSlots as $slot) {
|
$result = $this->smartMatchPreview($existingPieceTypes, $proposedPieceTypeIds);
|
||||||
$key = ($slot->getTypePiece()?->getId() ?? '').'|'.$slot->getPosition();
|
$addedPieceSlots += $result['added'];
|
||||||
$existingPieceKeys[$key] = true;
|
$deletedPieceSlots += $result['deleted'];
|
||||||
}
|
|
||||||
foreach ($proposedPieceKeys as $key => $_) {
|
|
||||||
if (!isset($existingPieceKeys[$key])) {
|
|
||||||
++$addedPieceSlots;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach ($existingPieceKeys as $key => $_) {
|
|
||||||
if (!isset($proposedPieceKeys[$key])) {
|
|
||||||
++$deletedPieceSlots;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Product slots
|
// Product slots
|
||||||
$productSlots = $this->em->getRepository(ComposantProductSlot::class)->findBy(['composant' => $composant]);
|
$productSlots = $this->em->getRepository(ComposantProductSlot::class)->findBy(['composant' => $composant]);
|
||||||
$existingProductKeys = [];
|
$existingProductTypes = array_map(fn (ComposantProductSlot $s) => $s->getTypeProduct()?->getId() ?? '', $productSlots);
|
||||||
foreach ($productSlots as $slot) {
|
$result = $this->smartMatchPreview($existingProductTypes, $proposedProductTypeIds);
|
||||||
$key = ($slot->getTypeProduct()?->getId() ?? '').'|'.$slot->getPosition();
|
$addedProductSlots += $result['added'];
|
||||||
$existingProductKeys[$key] = true;
|
$deletedProductSlots += $result['deleted'];
|
||||||
}
|
|
||||||
foreach ($proposedProductKeys as $key => $_) {
|
|
||||||
if (!isset($existingProductKeys[$key])) {
|
|
||||||
++$addedProductSlots;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach ($existingProductKeys as $key => $_) {
|
|
||||||
if (!isset($proposedProductKeys[$key])) {
|
|
||||||
++$deletedProductSlots;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subcomponent slots
|
// Subcomponent slots
|
||||||
$subSlots = $this->em->getRepository(ComposantSubcomponentSlot::class)->findBy(['composant' => $composant]);
|
$subSlots = $this->em->getRepository(ComposantSubcomponentSlot::class)->findBy(['composant' => $composant]);
|
||||||
$existingSubKeys = [];
|
$existingSubTypes = array_map(fn (ComposantSubcomponentSlot $s) => $s->getTypeComposant()?->getId() ?? '', $subSlots);
|
||||||
foreach ($subSlots as $slot) {
|
$result = $this->smartMatchPreview($existingSubTypes, $proposedSubTypeIds);
|
||||||
$key = ($slot->getTypeComposant()?->getId() ?? '').'|'.$slot->getPosition();
|
$addedSubSlots += $result['added'];
|
||||||
$existingSubKeys[$key] = true;
|
$deletedSubSlots += $result['deleted'];
|
||||||
}
|
|
||||||
foreach ($proposedSubKeys as $key => $_) {
|
|
||||||
if (!isset($existingSubKeys[$key])) {
|
|
||||||
++$addedSubSlots;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach ($existingSubKeys as $key => $_) {
|
|
||||||
if (!isset($proposedSubKeys[$key])) {
|
|
||||||
++$deletedSubSlots;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom field values
|
// Custom field values
|
||||||
$addedCfValues += $cfAdded;
|
$addedCfValues += $cfAdded;
|
||||||
@@ -187,31 +151,14 @@ class ComposantSyncStrategy implements SyncStrategyInterface
|
|||||||
$composants = $this->em->getRepository(Composant::class)->findBy(['typeComposant' => $modelType]);
|
$composants = $this->em->getRepository(Composant::class)->findBy(['typeComposant' => $modelType]);
|
||||||
|
|
||||||
// Load skeleton requirements
|
// Load skeleton requirements
|
||||||
$pieceReqs = $this->em->getRepository(SkeletonPieceRequirement::class)->findBy(['modelType' => $modelType]);
|
$pieceReqs = $this->em->getRepository(SkeletonPieceRequirement::class)->findBy(['modelType' => $modelType], ['position' => 'ASC']);
|
||||||
$productReqs = $this->em->getRepository(SkeletonProductRequirement::class)->findBy(['modelType' => $modelType]);
|
$productReqs = $this->em->getRepository(SkeletonProductRequirement::class)->findBy(['modelType' => $modelType], ['position' => 'ASC']);
|
||||||
$subReqs = $this->em->getRepository(SkeletonSubcomponentRequirement::class)->findBy(['modelType' => $modelType]);
|
$subReqs = $this->em->getRepository(SkeletonSubcomponentRequirement::class)->findBy(['modelType' => $modelType], ['position' => 'ASC']);
|
||||||
$customFields = $this->em->getRepository(CustomField::class)->findBy(
|
$customFields = $this->em->getRepository(CustomField::class)->findBy(
|
||||||
['typeComposant' => $modelType],
|
['typeComposant' => $modelType],
|
||||||
['orderIndex' => 'ASC']
|
['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;
|
$addedPieceSlots = 0;
|
||||||
$deletedPieceSlots = 0;
|
$deletedPieceSlots = 0;
|
||||||
$addedProductSlots = 0;
|
$addedProductSlots = 0;
|
||||||
@@ -225,108 +172,137 @@ class ComposantSyncStrategy implements SyncStrategyInterface
|
|||||||
foreach ($composants as $composant) {
|
foreach ($composants as $composant) {
|
||||||
$changed = false;
|
$changed = false;
|
||||||
|
|
||||||
// --- Piece slots — query from repository to avoid stale collection ---
|
// --- Piece slots ---
|
||||||
$pieceSlotEntities = $this->em->getRepository(ComposantPieceSlot::class)->findBy(['composant' => $composant]);
|
$pieceSlotEntities = $this->em->getRepository(ComposantPieceSlot::class)->findBy(['composant' => $composant]);
|
||||||
$existingPieceSlots = [];
|
$existingPieceTypeIds = array_map(fn (ComposantPieceSlot $s) => $s->getTypePiece()?->getId() ?? '', $pieceSlotEntities);
|
||||||
foreach ($pieceSlotEntities as $slot) {
|
$reqPieceTypeIds = array_map(fn (SkeletonPieceRequirement $r) => $r->getTypePiece()->getId(), $pieceReqs);
|
||||||
$key = ($slot->getTypePiece()?->getId() ?? '').'|'.$slot->getPosition();
|
$matchResult = $this->smartMatch($existingPieceTypeIds, $reqPieceTypeIds);
|
||||||
$existingPieceSlots[$key] = $slot;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add missing piece slots
|
// Update matched slots (position may have changed)
|
||||||
foreach ($pieceReqKeys as $key => $req) {
|
foreach ($matchResult['matched'] as [$slotIdx, $reqIdx]) {
|
||||||
if (!isset($existingPieceSlots[$key])) {
|
$slot = $pieceSlotEntities[$slotIdx];
|
||||||
$slot = new ComposantPieceSlot();
|
$req = $pieceReqs[$reqIdx];
|
||||||
$slot->setComposant($composant);
|
if ($slot->getPosition() !== $req->getPosition()) {
|
||||||
$slot->setTypePiece($req->getTypePiece());
|
|
||||||
$slot->setPosition($req->getPosition());
|
$slot->setPosition($req->getPosition());
|
||||||
// Default quantity = 1, selectedPiece = null (already defaults)
|
|
||||||
$this->em->persist($slot);
|
|
||||||
++$addedPieceSlots;
|
|
||||||
$changed = true;
|
$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
|
// Delete orphaned piece slots
|
||||||
if ($confirmation->confirmDeletions) {
|
if ($confirmation->confirmDeletions) {
|
||||||
foreach ($existingPieceSlots as $key => $slot) {
|
foreach ($matchResult['orphanedSlots'] as $slotIdx) {
|
||||||
if (!isset($pieceReqKeys[$key])) {
|
$slot = $pieceSlotEntities[$slotIdx];
|
||||||
$composant->removePieceSlot($slot);
|
$composant->removePieceSlot($slot);
|
||||||
$this->em->remove($slot);
|
$this->em->remove($slot);
|
||||||
++$deletedPieceSlots;
|
++$deletedPieceSlots;
|
||||||
$changed = true;
|
$changed = true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Product slots ---
|
// --- Product slots ---
|
||||||
$productSlotEntities = $this->em->getRepository(ComposantProductSlot::class)->findBy(['composant' => $composant]);
|
$productSlotEntities = $this->em->getRepository(ComposantProductSlot::class)->findBy(['composant' => $composant]);
|
||||||
$existingProductSlots = [];
|
$existingProductTypeIds = array_map(fn (ComposantProductSlot $s) => $s->getTypeProduct()?->getId() ?? '', $productSlotEntities);
|
||||||
foreach ($productSlotEntities as $slot) {
|
$reqProductTypeIds = array_map(fn (SkeletonProductRequirement $r) => $r->getTypeProduct()->getId(), $productReqs);
|
||||||
$key = ($slot->getTypeProduct()?->getId() ?? '').'|'.$slot->getPosition();
|
$matchResult = $this->smartMatch($existingProductTypeIds, $reqProductTypeIds);
|
||||||
$existingProductSlots[$key] = $slot;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add missing product slots
|
// Update matched slots
|
||||||
foreach ($productReqKeys as $key => $req) {
|
foreach ($matchResult['matched'] as [$slotIdx, $reqIdx]) {
|
||||||
if (!isset($existingProductSlots[$key])) {
|
$slot = $productSlotEntities[$slotIdx];
|
||||||
$slot = new ComposantProductSlot();
|
$req = $productReqs[$reqIdx];
|
||||||
$slot->setComposant($composant);
|
if ($slot->getPosition() !== $req->getPosition()) {
|
||||||
$slot->setTypeProduct($req->getTypeProduct());
|
|
||||||
$slot->setPosition($req->getPosition());
|
$slot->setPosition($req->getPosition());
|
||||||
if (null !== $req->getFamilyCode()) {
|
|
||||||
$slot->setFamilyCode($req->getFamilyCode());
|
|
||||||
}
|
|
||||||
$this->em->persist($slot);
|
|
||||||
++$addedProductSlots;
|
|
||||||
$changed = true;
|
$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
|
// Delete orphaned product slots
|
||||||
if ($confirmation->confirmDeletions) {
|
if ($confirmation->confirmDeletions) {
|
||||||
foreach ($existingProductSlots as $key => $slot) {
|
foreach ($matchResult['orphanedSlots'] as $slotIdx) {
|
||||||
if (!isset($productReqKeys[$key])) {
|
$slot = $productSlotEntities[$slotIdx];
|
||||||
$composant->removeProductSlot($slot);
|
$composant->removeProductSlot($slot);
|
||||||
$this->em->remove($slot);
|
$this->em->remove($slot);
|
||||||
++$deletedProductSlots;
|
++$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;
|
|
||||||
$changed = true;
|
$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
|
// Delete orphaned subcomponent slots
|
||||||
if ($confirmation->confirmDeletions) {
|
if ($confirmation->confirmDeletions) {
|
||||||
foreach ($existingSubSlots as $key => $slot) {
|
foreach ($matchResult['orphanedSlots'] as $slotIdx) {
|
||||||
if (!isset($subReqKeys[$key])) {
|
$slot = $subSlotEntities[$slotIdx];
|
||||||
$composant->removeSubcomponentSlot($slot);
|
$composant->removeSubcomponentSlot($slot);
|
||||||
$this->em->remove($slot);
|
$this->em->remove($slot);
|
||||||
++$deletedSubSlots;
|
++$deletedSubSlots;
|
||||||
$changed = true;
|
$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;
|
$addedCfValues = 0;
|
||||||
$deletedCfValues = 0;
|
$deletedCfValues = 0;
|
||||||
|
|
||||||
// Map proposed products by (typeProductId, position) keys — position defaults to array index
|
// Build proposed typeId list
|
||||||
$proposedProductKeys = [];
|
$proposedProductTypeIds = [];
|
||||||
foreach ($proposedProducts as $i => $pp) {
|
foreach ($proposedProducts as $pp) {
|
||||||
$pos = $pp['position'] ?? $i;
|
$proposedProductTypeIds[] = $pp['typeProductId'];
|
||||||
$proposedProductKeys[$pp['typeProductId'].'|'.$pos] = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map proposed custom fields by orderIndex (falls back to array index)
|
// Map proposed custom fields by orderIndex (falls back to array index)
|
||||||
@@ -80,23 +79,12 @@ class PieceSyncStrategy implements SyncStrategyInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($pieces as $piece) {
|
foreach ($pieces as $piece) {
|
||||||
// Product slots
|
// Product slots — smart matching by typeId
|
||||||
$productSlots = $this->em->getRepository(PieceProductSlot::class)->findBy(['piece' => $piece]);
|
$productSlots = $this->em->getRepository(PieceProductSlot::class)->findBy(['piece' => $piece]);
|
||||||
$existingProductKeys = [];
|
$existingProductTypes = array_map(fn (PieceProductSlot $s) => $s->getTypeProduct()?->getId() ?? '', $productSlots);
|
||||||
foreach ($productSlots as $slot) {
|
$result = $this->smartMatchPreview($existingProductTypes, $proposedProductTypeIds);
|
||||||
$key = ($slot->getTypeProduct()?->getId() ?? '').'|'.$slot->getPosition();
|
$addedProductSlots += $result['added'];
|
||||||
$existingProductKeys[$key] = true;
|
$deletedProductSlots += $result['deleted'];
|
||||||
}
|
|
||||||
foreach ($proposedProductKeys as $key => $_) {
|
|
||||||
if (!isset($existingProductKeys[$key])) {
|
|
||||||
++$addedProductSlots;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach ($existingProductKeys as $key => $_) {
|
|
||||||
if (!isset($proposedProductKeys[$key])) {
|
|
||||||
++$deletedProductSlots;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom field values
|
// Custom field values
|
||||||
$addedCfValues += $cfAdded;
|
$addedCfValues += $cfAdded;
|
||||||
@@ -125,18 +113,12 @@ class PieceSyncStrategy implements SyncStrategyInterface
|
|||||||
$pieces = $this->em->getRepository(Piece::class)->findBy(['typePiece' => $modelType]);
|
$pieces = $this->em->getRepository(Piece::class)->findBy(['typePiece' => $modelType]);
|
||||||
|
|
||||||
// Load skeleton requirements
|
// 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(
|
$customFields = $this->em->getRepository(CustomField::class)->findBy(
|
||||||
['typePiece' => $modelType],
|
['typePiece' => $modelType],
|
||||||
['orderIndex' => 'ASC']
|
['orderIndex' => 'ASC']
|
||||||
);
|
);
|
||||||
|
|
||||||
// Map requirements by (typeProductId, position)
|
|
||||||
$productReqKeys = [];
|
|
||||||
foreach ($productReqs as $req) {
|
|
||||||
$productReqKeys[$req->getTypeProduct()->getId().'|'.$req->getPosition()] = $req;
|
|
||||||
}
|
|
||||||
|
|
||||||
$addedProductSlots = 0;
|
$addedProductSlots = 0;
|
||||||
$deletedProductSlots = 0;
|
$deletedProductSlots = 0;
|
||||||
$addedCfValues = 0;
|
$addedCfValues = 0;
|
||||||
@@ -147,38 +129,48 @@ class PieceSyncStrategy implements SyncStrategyInterface
|
|||||||
$changed = false;
|
$changed = false;
|
||||||
|
|
||||||
// --- Product slots ---
|
// --- Product slots ---
|
||||||
$productSlotEntities = $this->em->getRepository(PieceProductSlot::class)->findBy(['piece' => $piece]);
|
$productSlotEntities = $this->em->getRepository(PieceProductSlot::class)->findBy(['piece' => $piece]);
|
||||||
$existingProductSlots = [];
|
$existingProductTypeIds = array_map(fn (PieceProductSlot $s) => $s->getTypeProduct()?->getId() ?? '', $productSlotEntities);
|
||||||
foreach ($productSlotEntities as $slot) {
|
$reqProductTypeIds = array_map(fn (SkeletonProductRequirement $r) => $r->getTypeProduct()->getId(), $productReqs);
|
||||||
$key = ($slot->getTypeProduct()?->getId() ?? '').'|'.$slot->getPosition();
|
$matchResult = $this->smartMatch($existingProductTypeIds, $reqProductTypeIds);
|
||||||
$existingProductSlots[$key] = $slot;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add missing product slots
|
// Update matched slots (position/familyCode may have changed)
|
||||||
foreach ($productReqKeys as $key => $req) {
|
foreach ($matchResult['matched'] as [$slotIdx, $reqIdx]) {
|
||||||
if (!isset($existingProductSlots[$key])) {
|
$slot = $productSlotEntities[$slotIdx];
|
||||||
$slot = new PieceProductSlot();
|
$req = $productReqs[$reqIdx];
|
||||||
$slot->setPiece($piece);
|
if ($slot->getPosition() !== $req->getPosition()) {
|
||||||
$slot->setTypeProduct($req->getTypeProduct());
|
|
||||||
$slot->setPosition($req->getPosition());
|
$slot->setPosition($req->getPosition());
|
||||||
if (null !== $req->getFamilyCode()) {
|
$changed = true;
|
||||||
$slot->setFamilyCode($req->getFamilyCode());
|
}
|
||||||
}
|
if ($slot->getFamilyCode() !== $req->getFamilyCode()) {
|
||||||
$this->em->persist($slot);
|
$slot->setFamilyCode($req->getFamilyCode());
|
||||||
++$addedProductSlots;
|
|
||||||
$changed = true;
|
$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
|
// Delete orphaned product slots
|
||||||
if ($confirmation->confirmDeletions) {
|
if ($confirmation->confirmDeletions) {
|
||||||
foreach ($existingProductSlots as $key => $slot) {
|
foreach ($matchResult['orphanedSlots'] as $slotIdx) {
|
||||||
if (!isset($productReqKeys[$key])) {
|
$slot = $productSlotEntities[$slotIdx];
|
||||||
$piece->removeProductSlot($slot);
|
$piece->removeProductSlot($slot);
|
||||||
$this->em->remove($slot);
|
$this->em->remove($slot);
|
||||||
++$deletedProductSlots;
|
++$deletedProductSlots;
|
||||||
$changed = true;
|
$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"
|
"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": {
|
"phpunit/phpunit": {
|
||||||
"version": "12.5",
|
"version": "12.5",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
@@ -154,6 +166,9 @@
|
|||||||
".editorconfig"
|
".editorconfig"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"symfony/mcp-bundle": {
|
||||||
|
"version": "v0.6.0"
|
||||||
|
},
|
||||||
"symfony/property-info": {
|
"symfony/property-info": {
|
||||||
"version": "8.0",
|
"version": "8.0",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user