Compare commits
18 Commits
57615b3e9d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65fbd38b55 | ||
|
|
37aa755819 | ||
|
|
98caaa148d | ||
|
|
523eed927e | ||
|
|
43bec07bb8 | ||
|
|
0181f18778 | ||
|
|
8e0acf4896 | ||
|
|
aa8e043c83 | ||
|
|
b2aff0e414 | ||
|
|
4072abf7ba | ||
|
|
089ca43404 | ||
|
|
f09c7e4782 | ||
|
|
6a20dcce54 | ||
|
|
6e0be3dbf3 | ||
|
|
f66db3f2f0 | ||
|
|
0864af1439 | ||
|
|
5210e53d73 | ||
|
|
3f07162b94 |
38
CLAUDE.md
38
CLAUDE.md
@@ -64,6 +64,14 @@ npm run build # Build production
|
||||
npm run lint:fix # ESLint fix
|
||||
npx nuxi typecheck # TypeScript check (0 errors attendu)
|
||||
|
||||
# Database / Fixtures
|
||||
make db-reset # Reset database (drop + recreate schema)
|
||||
make fixtures-dump # Dump la DB vers fixtures/data.sql
|
||||
make fixtures-load # Charger les fixtures SQL (désactive FK)
|
||||
make fixtures-reset # Reset DB + recharger fixtures
|
||||
make import-data # Importer les dumps SQL normalisés
|
||||
make cache-clear # Clear cache Symfony
|
||||
|
||||
# Release
|
||||
./scripts/release.sh patch # Bump patch version (ou minor/major)
|
||||
```
|
||||
@@ -101,6 +109,11 @@ Le frontend est un submodule git. Lors d'un commit frontend :
|
||||
### Entités Principales
|
||||
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
|
||||
|
||||
#### Entités de normalisation (slots & skeleton requirements)
|
||||
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
|
||||
- **Slots** (données réelles d'un composant) : `ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot`
|
||||
- **Skeleton Requirements** (définitions du ModelType) : `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
|
||||
|
||||
### Patterns
|
||||
- **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
|
||||
- **ORM** : Attributs PHP 8 (`#[ORM\Column(...)]`, `#[Groups([...])]`)
|
||||
@@ -110,15 +123,32 @@ Le frontend est un submodule git. Lors d'un commit frontend :
|
||||
- **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence
|
||||
|
||||
### Custom Controllers (pas API Platform)
|
||||
- `MachineStructureController` — `/api/machines/{id}/structure` (GET/PATCH) : hiérarchie complète machine avec normalisation JSON manuelle (pas Symfony Serializer). Source principale de données pour la page détail machine.
|
||||
- `MachineStructureController` — `/api/machines/{id}/structure` (GET/PATCH), `/api/machines/{id}/clone` (POST) : hiérarchie complète machine avec normalisation JSON manuelle. Source principale de données pour la page détail machine.
|
||||
- `MachineCustomFieldsController` — `/api/machines/{id}/add-custom-fields` (POST) : initialise les CustomFieldValue manquants pour une machine.
|
||||
- `CustomFieldValueController` — `/api/custom-fields/values/*` : CRUD + upsert pour les valeurs de champs perso.
|
||||
- `ComposantPieceSlotController` — `/api/composant-piece-slots/{id}` (PATCH) : mise à jour des slots pièce d'un composant.
|
||||
- `SessionProfileController` — `/api/session/profile` (GET/POST/DELETE) : auth session (login/logout/current user).
|
||||
- `SessionProfilesController` — `/api/session/profiles` (GET) : liste des profils disponibles pour la session.
|
||||
- `AdminProfileController` — `/api/admin/profiles` : CRUD profils, gestion rôles et mots de passe (ROLE_ADMIN).
|
||||
- `CommentController` — `/api/comments` : création, résolution, compteur non-résolus.
|
||||
- `ActivityLogController` — `/api/activity-logs` (GET) : journal d'activité global.
|
||||
- `EntityHistoryController` — `/api/{entity}/{id}/history` (GET) : historique audit par entité (machines, pièces, composants, produits).
|
||||
- `DocumentQueryController` — `/api/documents/{entity}/{id}` (GET) : documents par site/machine/composant/pièce/produit.
|
||||
- `DocumentServeController` — `/api/documents/{id}/file|download` (GET) : servir/télécharger fichiers.
|
||||
- `ModelTypeConversionController` — `/api/model_types/{id}/conversion-check|convert` : vérification et conversion de ModelType.
|
||||
- `HealthCheckController` — `/api/health` (GET) : health check.
|
||||
|
||||
### Custom Fields — Architecture
|
||||
- **Composants/Pièces/Produits** : définitions dans le JSON `structure` du ModelType
|
||||
- **Composants/Pièces/Produits** : définitions dans les entités `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` du ModelType (anciennement JSON `structure`, normalisé en tables relationnelles). Les custom fields de ces entités sont définis dans `customFields` JSON sur chaque Skeleton*Requirement.
|
||||
- **Machines** : définitions = entités `CustomField` liées directement via `machineId` FK (pas de ModelType)
|
||||
- Les deux partagent la même entité `CustomFieldValue` pour stocker les valeurs
|
||||
|
||||
### Normalisation JSON → Tables (architecture slots)
|
||||
Les anciennes colonnes JSON `structure` et `productIds` des Composants ont été remplacées par des tables relationnelles :
|
||||
- **ModelType** définit le squelette via `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
|
||||
- **Composant** stocke les données réelles via `ComposantPieceSlot`, `ComposantProductSlot`, `ComposantSubcomponentSlot`
|
||||
- Chaque slot référence son skeleton requirement (`skeletonRequirement` FK) + l'entité sélectionnée + position
|
||||
|
||||
### Rôles (hiérarchie)
|
||||
```
|
||||
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
||||
@@ -193,8 +223,8 @@ make test-setup # Créer/mettre à jour le schéma test
|
||||
### Pattern de test
|
||||
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
|
||||
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
|
||||
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`
|
||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`
|
||||
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`
|
||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
|
||||
|
||||
## URLs Locales
|
||||
- API Symfony : `http://localhost:8081/api`
|
||||
|
||||
Submodule Inventory_frontend updated: 5912216a89...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
@@ -1,7 +1,7 @@
|
||||
api_platform:
|
||||
title: Inventory API
|
||||
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
||||
version: 1.8.1
|
||||
version: 1.9.1
|
||||
defaults:
|
||||
stateless: false
|
||||
cache_headers:
|
||||
|
||||
10
config/packages/http_discovery.yaml
Normal file
10
config/packages/http_discovery.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
|
||||
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
|
||||
|
||||
http_discovery.psr17_factory:
|
||||
class: Http\Discovery\Psr17Factory
|
||||
20
config/packages/mcp.yaml.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?$
|
||||
security: false
|
||||
|
||||
# TODO: re-enable when symfony/ai-mcp-bundle is installed
|
||||
# mcp:
|
||||
# pattern: ^/_mcp
|
||||
# stateless: true
|
||||
# custom_authenticators:
|
||||
# - App\Mcp\Security\McpHeaderAuthenticator
|
||||
|
||||
api:
|
||||
pattern: ^/api
|
||||
stateless: false
|
||||
@@ -49,6 +56,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 } # TODO: re-enable with MCP
|
||||
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/\.well-known, roles: PUBLIC_ACCESS }
|
||||
|
||||
@@ -1626,6 +1626,19 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* nelmio_cors?: NelmioCorsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* "when@dev"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
* services?: ServicesConfig,
|
||||
* framework?: FrameworkConfig,
|
||||
* twig?: TwigConfig,
|
||||
* security?: SecurityConfig,
|
||||
* doctrine?: DoctrineConfig,
|
||||
* doctrine_migrations?: DoctrineMigrationsConfig,
|
||||
* nelmio_cors?: NelmioCorsConfig,
|
||||
* api_platform?: ApiPlatformConfig,
|
||||
* lexik_jwt_authentication?: LexikJwtAuthenticationConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1732,6 +1745,7 @@ namespace Symfony\Component\Routing\Loader\Configurator;
|
||||
* deprecated?: array{package:string, version:string, message?:string},
|
||||
* }
|
||||
* @psalm-type RoutesConfig = array{
|
||||
* "when@dev"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||
* "when@prod"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||
* "when@test"?: array<string, RouteConfig|ImportConfig|AliasConfig>,
|
||||
* ...<string, RouteConfig|ImportConfig|AliasConfig>
|
||||
|
||||
@@ -18,6 +18,8 @@ services:
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
App\:
|
||||
resource: '../src/'
|
||||
exclude:
|
||||
- '../src/Mcp/'
|
||||
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
@@ -34,7 +36,34 @@ services:
|
||||
tags:
|
||||
- { 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:
|
||||
decorates: 'api_platform.openapi.factory'
|
||||
arguments:
|
||||
$decorated: '@.inner'
|
||||
|
||||
when@test:
|
||||
services:
|
||||
App\Service\Sync\ProductSyncStrategy:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\Sync\ComposantSyncStrategy:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\Sync\PieceSyncStrategy:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
App\Service\ModelTypeSyncService:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
public: true
|
||||
|
||||
1067
docs/superpowers/plans/2026-03-12-json-to-tables-normalization.md
Normal file
1067
docs/superpowers/plans/2026-03-12-json-to-tables-normalization.md
Normal file
File diff suppressed because it is too large
Load Diff
1582
docs/superpowers/plans/2026-03-13-modeltype-sync.md
Normal file
1582
docs/superpowers/plans/2026-03-13-modeltype-sync.md
Normal file
File diff suppressed because it is too large
Load Diff
118
migrations/Version20260313124029.php
Normal file
118
migrations/Version20260313124029.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Create piece_product_slots table (mirroring composant_product_slots)
|
||||
* and add version columns to composants, pieces, products.
|
||||
*/
|
||||
final class Version20260313124029 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create piece_product_slots table, add version columns to composants/pieces/products, migrate piece_products data';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// ── Create piece_product_slots table (idempotent) ─────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS piece_product_slots (
|
||||
id VARCHAR(36) NOT NULL,
|
||||
"pieceid" VARCHAR(36) NOT NULL,
|
||||
"typeproductid" VARCHAR(36) DEFAULT NULL,
|
||||
"selectedproductid" VARCHAR(36) DEFAULT NULL,
|
||||
"familycode" VARCHAR(255) DEFAULT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
"createdat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
"updatedat" TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
)
|
||||
SQL);
|
||||
|
||||
// ── Indexes (idempotent) ──────────────────────────────────────────────
|
||||
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_prod_slot_piece ON piece_product_slots ("pieceid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_prod_slot_type ON piece_product_slots ("typeproductid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_prod_slot_selected ON piece_product_slots ("selectedproductid")');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_piece_product_slots_piece_pos ON piece_product_slots ("pieceid", position)');
|
||||
|
||||
// ── Foreign keys (idempotent via DO $$ block) ─────────────────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_piece_prod_slot_piece') THEN
|
||||
ALTER TABLE piece_product_slots
|
||||
ADD CONSTRAINT fk_piece_prod_slot_piece
|
||||
FOREIGN KEY ("pieceid") REFERENCES pieces (id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_piece_prod_slot_type') THEN
|
||||
ALTER TABLE piece_product_slots
|
||||
ADD CONSTRAINT fk_piece_prod_slot_type
|
||||
FOREIGN KEY ("typeproductid") REFERENCES model_types (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_piece_prod_slot_selected') THEN
|
||||
ALTER TABLE piece_product_slots
|
||||
ADD CONSTRAINT fk_piece_prod_slot_selected
|
||||
FOREIGN KEY ("selectedproductid") REFERENCES products (id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
|
||||
// ── Add version columns (idempotent) ─────────────────────────────────
|
||||
|
||||
$this->addSql('ALTER TABLE composants ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
|
||||
$this->addSql('ALTER TABLE pieces ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
|
||||
$this->addSql('ALTER TABLE products ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 1');
|
||||
|
||||
// ── Data migration: piece_products → piece_product_slots ─────────────
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'piece_products') THEN
|
||||
INSERT INTO piece_product_slots (id, "pieceid", "typeproductid", "selectedproductid", "familycode", position, "createdat", "updatedat")
|
||||
SELECT
|
||||
'cl' || encode(gen_random_bytes(12), 'hex'),
|
||||
pp.piece_id,
|
||||
p.typeproductid,
|
||||
pp.product_id,
|
||||
NULL,
|
||||
ROW_NUMBER() OVER (PARTITION BY pp.piece_id ORDER BY pp.product_id) - 1,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM piece_products pp
|
||||
JOIN products p ON p.id = pp.product_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM piece_product_slots pps
|
||||
WHERE pps."pieceid" = pp.piece_id AND pps."selectedproductid" = pp.product_id
|
||||
);
|
||||
END IF;
|
||||
END $$
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE IF EXISTS piece_product_slots');
|
||||
$this->addSql('ALTER TABLE composants DROP COLUMN IF EXISTS version');
|
||||
$this->addSql('ALTER TABLE pieces DROP COLUMN IF EXISTS version');
|
||||
$this->addSql('ALTER TABLE products DROP COLUMN IF EXISTS version');
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
<testsuites>
|
||||
<testsuite name="Project Test Suite">
|
||||
<directory>tests</directory>
|
||||
<exclude>tests/Mcp</exclude>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\ComposantPieceSlot;
|
||||
use App\Entity\Piece;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
@@ -37,12 +38,22 @@ class ComposantPieceSlotController extends AbstractController
|
||||
$slot->setQuantity(max(1, (int) $payload['quantity']));
|
||||
}
|
||||
|
||||
if (array_key_exists('selectedPieceId', $payload)) {
|
||||
if (null === $payload['selectedPieceId']) {
|
||||
$slot->setSelectedPiece(null);
|
||||
} else {
|
||||
$piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']);
|
||||
$slot->setSelectedPiece($piece);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'id' => $slot->getId(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'success' => true,
|
||||
'id' => $slot->getId(),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
54
src/Controller/ComposantProductSlotController.php
Normal file
54
src/Controller/ComposantProductSlotController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\ComposantProductSlot;
|
||||
use App\Entity\Product;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/composant-product-slots')]
|
||||
class ComposantProductSlotController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}', name: 'composant_product_slot_patch', methods: ['PATCH'])]
|
||||
public function patch(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$slot = $this->entityManager->find(ComposantProductSlot::class, $id);
|
||||
if (!$slot) {
|
||||
return $this->json(['success' => false, 'error' => 'Slot not found.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
|
||||
}
|
||||
|
||||
if (array_key_exists('selectedProductId', $payload)) {
|
||||
if (null === $payload['selectedProductId']) {
|
||||
$slot->setSelectedProduct(null);
|
||||
} else {
|
||||
$product = $this->entityManager->find(Product::class, $payload['selectedProductId']);
|
||||
$slot->setSelectedProduct($product);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'id' => $slot->getId(),
|
||||
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
54
src/Controller/ComposantSubcomponentSlotController.php
Normal file
54
src/Controller/ComposantSubcomponentSlotController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\ComposantSubcomponentSlot;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/composant-subcomponent-slots')]
|
||||
class ComposantSubcomponentSlotController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
) {}
|
||||
|
||||
#[Route('/{id}', name: 'composant_subcomponent_slot_patch', methods: ['PATCH'])]
|
||||
public function patch(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$slot = $this->entityManager->find(ComposantSubcomponentSlot::class, $id);
|
||||
if (!$slot) {
|
||||
return $this->json(['success' => false, 'error' => 'Slot not found.'], 404);
|
||||
}
|
||||
|
||||
$payload = json_decode($request->getContent(), true);
|
||||
if (!is_array($payload)) {
|
||||
return $this->json(['success' => false, 'error' => 'Invalid JSON payload.'], 400);
|
||||
}
|
||||
|
||||
if (array_key_exists('selectedComposantId', $payload)) {
|
||||
if (null === $payload['selectedComposantId']) {
|
||||
$slot->setSelectedComposant(null);
|
||||
} else {
|
||||
$composant = $this->entityManager->find(Composant::class, $payload['selectedComposantId']);
|
||||
$slot->setSelectedComposant($composant);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $this->json([
|
||||
'success' => true,
|
||||
'id' => $slot->getId(),
|
||||
'selectedComposantId' => $slot->getSelectedComposant()?->getId(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
74
src/Controller/ModelTypeSyncController.php
Normal file
74
src/Controller/ModelTypeSyncController.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use App\Service\ModelTypeSyncService;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
#[Route('/api/model_types/{id}')]
|
||||
final class ModelTypeSyncController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
private readonly ModelTypeSyncService $syncService,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
#[Route('/sync-preview', name: 'api_model_type_sync_preview', methods: ['POST'])]
|
||||
public function preview(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$modelType = $this->modelTypes->find($id);
|
||||
|
||||
if (!$modelType) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Catégorie introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$structure = $body['structure'] ?? [];
|
||||
|
||||
$result = $this->syncService->preview($modelType, $structure);
|
||||
|
||||
return new JsonResponse($result);
|
||||
}
|
||||
|
||||
#[Route('/sync', name: 'api_model_type_sync', methods: ['POST'])]
|
||||
public function sync(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_GESTIONNAIRE');
|
||||
|
||||
$modelType = $this->modelTypes->find($id);
|
||||
|
||||
if (!$modelType) {
|
||||
return new JsonResponse(
|
||||
['message' => 'Catégorie introuvable.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
$body = json_decode($request->getContent(), true);
|
||||
$confirmation = new SyncConfirmation(
|
||||
confirmDeletions: $body['confirmDeletions'] ?? false,
|
||||
confirmTypeChanges: $body['confirmTypeChanges'] ?? false,
|
||||
);
|
||||
|
||||
$result = $this->em->wrapInTransaction(function () use ($modelType, $confirmation) {
|
||||
return $this->syncService->execute($modelType, $confirmation);
|
||||
});
|
||||
|
||||
return new JsonResponse($result);
|
||||
}
|
||||
}
|
||||
13
src/DTO/SyncConfirmation.php
Normal file
13
src/DTO/SyncConfirmation.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
class SyncConfirmation
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $confirmDeletions = false,
|
||||
public readonly bool $confirmTypeChanges = false,
|
||||
) {}
|
||||
}
|
||||
27
src/DTO/SyncExecutionResult.php
Normal file
27
src/DTO/SyncExecutionResult.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
class SyncExecutionResult implements JsonSerializable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $itemsUpdated,
|
||||
public readonly array $additions = [],
|
||||
public readonly array $deletions = [],
|
||||
public readonly array $modifications = [],
|
||||
) {}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'itemsUpdated' => $this->itemsUpdated,
|
||||
'additions' => $this->additions,
|
||||
'deletions' => $this->deletions,
|
||||
'modifications' => $this->modifications,
|
||||
];
|
||||
}
|
||||
}
|
||||
38
src/DTO/SyncPreviewResult.php
Normal file
38
src/DTO/SyncPreviewResult.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\DTO;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
class SyncPreviewResult implements JsonSerializable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $modelTypeId,
|
||||
public readonly string $category,
|
||||
public readonly int $itemCount,
|
||||
public readonly array $additions = [],
|
||||
public readonly array $deletions = [],
|
||||
public readonly array $modifications = [],
|
||||
) {}
|
||||
|
||||
public function hasImpact(): bool
|
||||
{
|
||||
return array_sum($this->additions) > 0
|
||||
|| array_sum($this->deletions) > 0
|
||||
|| array_sum($this->modifications) > 0;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'modelTypeId' => $this->modelTypeId,
|
||||
'category' => $this->category,
|
||||
'itemCount' => $this->itemCount,
|
||||
'additions' => $this->additions,
|
||||
'deletions' => $this->deletions,
|
||||
'modifications' => $this->modifications,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,10 @@ class Composant
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $productSlots;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Groups(['composant:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['composant:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -406,4 +410,16 @@ class Composant
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function incrementVersion(): static
|
||||
{
|
||||
++$this->version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,4 +184,40 @@ class CustomField
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeComposant(): ?ModelType
|
||||
{
|
||||
return $this->typeComposant;
|
||||
}
|
||||
|
||||
public function setTypeComposant(?ModelType $typeComposant): static
|
||||
{
|
||||
$this->typeComposant = $typeComposant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypePiece(): ?ModelType
|
||||
{
|
||||
return $this->typePiece;
|
||||
}
|
||||
|
||||
public function setTypePiece(?ModelType $typePiece): static
|
||||
{
|
||||
$this->typePiece = $typePiece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ?ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(?ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,12 +114,23 @@ class Piece
|
||||
#[ORM\InverseJoinColumn(name: 'product_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
|
||||
private Collection $products;
|
||||
|
||||
/**
|
||||
* @var Collection<int, PieceProductSlot>
|
||||
*/
|
||||
#[ORM\OneToMany(targetEntity: PieceProductSlot::class, mappedBy: 'piece', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
#[ORM\OrderBy(['position' => 'ASC'])]
|
||||
private Collection $productSlots;
|
||||
|
||||
/**
|
||||
* @var Collection<int, MachinePieceLink>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'piece', targetEntity: MachinePieceLink::class)]
|
||||
private Collection $machineLinks;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Groups(['piece:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['piece:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -136,6 +147,7 @@ class Piece
|
||||
$this->documents = new ArrayCollection();
|
||||
$this->customFieldValues = new ArrayCollection();
|
||||
$this->products = new ArrayCollection();
|
||||
$this->productSlots = new ArrayCollection();
|
||||
$this->machineLinks = new ArrayCollection();
|
||||
}
|
||||
|
||||
@@ -287,4 +299,41 @@ class Piece
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, PieceProductSlot>
|
||||
*/
|
||||
public function getProductSlots(): Collection
|
||||
{
|
||||
return $this->productSlots;
|
||||
}
|
||||
|
||||
public function addProductSlot(PieceProductSlot $slot): static
|
||||
{
|
||||
if (!$this->productSlots->contains($slot)) {
|
||||
$this->productSlots->add($slot);
|
||||
$slot->setPiece($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeProductSlot(PieceProductSlot $slot): static
|
||||
{
|
||||
$this->productSlots->removeElement($slot);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function incrementVersion(): static
|
||||
{
|
||||
++$this->version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
112
src/Entity/PieceProductSlot.php
Normal file
112
src/Entity/PieceProductSlot.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Entity\Trait\CuidEntityTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'piece_product_slots')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
class PieceProductSlot
|
||||
{
|
||||
use CuidEntityTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\Column(type: Types::STRING, length: 36)]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'productSlots')]
|
||||
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Piece $piece;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'typeProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $typeProduct = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Product::class)]
|
||||
#[ORM\JoinColumn(name: 'selectedProductId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Product $selectedProduct = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'familyCode')]
|
||||
private ?string $familyCode = null;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 0])]
|
||||
private int $position = 0;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
private DateTimeImmutable $createdAt;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
|
||||
private DateTimeImmutable $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
}
|
||||
|
||||
public function getPiece(): Piece
|
||||
{
|
||||
return $this->piece;
|
||||
}
|
||||
|
||||
public function setPiece(Piece $piece): static
|
||||
{
|
||||
$this->piece = $piece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTypeProduct(): ?ModelType
|
||||
{
|
||||
return $this->typeProduct;
|
||||
}
|
||||
|
||||
public function setTypeProduct(?ModelType $typeProduct): static
|
||||
{
|
||||
$this->typeProduct = $typeProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSelectedProduct(): ?Product
|
||||
{
|
||||
return $this->selectedProduct;
|
||||
}
|
||||
|
||||
public function setSelectedProduct(?Product $selectedProduct): static
|
||||
{
|
||||
$this->selectedProduct = $selectedProduct;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getFamilyCode(): ?string
|
||||
{
|
||||
return $this->familyCode;
|
||||
}
|
||||
|
||||
public function setFamilyCode(?string $familyCode): static
|
||||
{
|
||||
$this->familyCode = $familyCode;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPosition(): int
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
public function setPosition(int $position): static
|
||||
{
|
||||
$this->position = $position;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -118,6 +118,10 @@ class Product
|
||||
#[ORM\OneToMany(mappedBy: 'product', targetEntity: MachineProductLink::class)]
|
||||
private Collection $machineLinks;
|
||||
|
||||
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
|
||||
#[Groups(['product:read'])]
|
||||
private int $version = 1;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['product:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -234,4 +238,16 @@ class Product
|
||||
{
|
||||
return $this->linkedPieces;
|
||||
}
|
||||
|
||||
public function getVersion(): int
|
||||
{
|
||||
return $this->version;
|
||||
}
|
||||
|
||||
public function incrementVersion(): static
|
||||
{
|
||||
++$this->version;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
44
src/Service/ModelTypeSyncService.php
Normal file
44
src/Service/ModelTypeSyncService.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\DTO\SyncExecutionResult;
|
||||
use App\DTO\SyncPreviewResult;
|
||||
use App\Entity\ModelType;
|
||||
use App\Service\Sync\SyncStrategyInterface;
|
||||
use LogicException;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
|
||||
|
||||
class ModelTypeSyncService
|
||||
{
|
||||
/** @param iterable<SyncStrategyInterface> $strategies */
|
||||
public function __construct(
|
||||
#[AutowireIterator('app.sync_strategy')]
|
||||
private readonly iterable $strategies,
|
||||
) {}
|
||||
|
||||
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult
|
||||
{
|
||||
foreach ($this->strategies as $strategy) {
|
||||
if ($strategy->supports($modelType)) {
|
||||
return $strategy->preview($modelType, $newStructure);
|
||||
}
|
||||
}
|
||||
|
||||
throw new LogicException('No sync strategy found for category: '.$modelType->getCategory()->value);
|
||||
}
|
||||
|
||||
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult
|
||||
{
|
||||
foreach ($this->strategies as $strategy) {
|
||||
if ($strategy->supports($modelType)) {
|
||||
return $strategy->execute($modelType, $confirmation);
|
||||
}
|
||||
}
|
||||
|
||||
throw new LogicException('No sync strategy found for category: '.$modelType->getCategory()->value);
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\SkeletonPieceRequirement;
|
||||
use App\Entity\SkeletonProductRequirement;
|
||||
use App\Entity\SkeletonSubcomponentRequirement;
|
||||
use App\Enum\ModelCategory;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
class SkeletonStructureService
|
||||
@@ -16,49 +18,265 @@ 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'] ?? []);
|
||||
|
||||
// Update product requirements in-place
|
||||
$this->syncProductRequirements($modelType, $structure['products'] ?? []);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
foreach ($modelType->getSkeletonProductRequirements() as $req) {
|
||||
$modelType->removeSkeletonProductRequirement($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];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($modelType->getSkeletonSubcomponentRequirements() as $req) {
|
||||
$modelType->removeSkeletonSubcomponentRequirement($req);
|
||||
// Remove unmatched existing requirements
|
||||
foreach ($existing as $req) {
|
||||
if (!isset($matched[spl_object_id($req)])) {
|
||||
$modelType->removeSkeletonPieceRequirement($req);
|
||||
}
|
||||
}
|
||||
|
||||
// Create piece requirements
|
||||
foreach (($structure['pieces'] ?? []) as $i => $pieceData) {
|
||||
// Create new requirements
|
||||
foreach ($toCreate as $item) {
|
||||
$req = new SkeletonPieceRequirement();
|
||||
$req->setModelType($modelType);
|
||||
$req->setTypePiece($this->em->getReference(ModelType::class, $pieceData['typePieceId']));
|
||||
$req->setPosition($i);
|
||||
$req->setTypePiece($this->em->getReference(ModelType::class, $item['data']['typePieceId']));
|
||||
$req->setPosition($item['position']);
|
||||
$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);
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
// Create subcomponent requirements (component types only)
|
||||
foreach (($structure['subcomponents'] ?? []) as $i => $subData) {
|
||||
$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($subData['alias'] ?? '');
|
||||
$req->setFamilyCode($subData['familyCode'] ?? '');
|
||||
if (!empty($subData['typeComposantId'])) {
|
||||
$req->setTypeComposant($this->em->getReference(ModelType::class, $subData['typeComposantId']));
|
||||
$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($i);
|
||||
$req->setPosition($item['position']);
|
||||
$modelType->addSkeletonSubcomponentRequirement($req);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync CustomField entities for this ModelType.
|
||||
* Handles two frontend formats:
|
||||
* - COMPONENT: {key, value: {type, required, options?, defaultValue?}, id?, customFieldId?}
|
||||
* - PIECE/PRODUCT: {name, type, required, options?, orderIndex?, defaultValue?}.
|
||||
*/
|
||||
private function updateCustomFields(ModelType $modelType, array $proposedFields): void
|
||||
{
|
||||
// Determine which FK to use based on category
|
||||
$category = $modelType->getCategory();
|
||||
$fkField = match ($category) {
|
||||
ModelCategory::COMPONENT => 'typeComposant',
|
||||
ModelCategory::PIECE => 'typePiece',
|
||||
ModelCategory::PRODUCT => 'typeProduct',
|
||||
};
|
||||
|
||||
// Load existing custom fields
|
||||
$existingFields = $this->em->getRepository(CustomField::class)->findBy(
|
||||
[$fkField => $modelType],
|
||||
['orderIndex' => 'ASC']
|
||||
);
|
||||
|
||||
// Index existing by ID for matching
|
||||
$existingById = [];
|
||||
foreach ($existingFields as $cf) {
|
||||
$existingById[$cf->getId()] = $cf;
|
||||
}
|
||||
|
||||
$processedIds = [];
|
||||
|
||||
foreach ($proposedFields as $i => $fieldData) {
|
||||
// Normalize both formats to a common shape
|
||||
$normalized = $this->normalizeCustomFieldData($fieldData, $i);
|
||||
|
||||
// Try to match an existing field by ID
|
||||
$existingField = null;
|
||||
$fieldId = $fieldData['customFieldId'] ?? $fieldData['id'] ?? null;
|
||||
if ($fieldId && isset($existingById[$fieldId])) {
|
||||
$existingField = $existingById[$fieldId];
|
||||
}
|
||||
|
||||
if ($existingField) {
|
||||
// Update existing field
|
||||
$existingField->setName($normalized['name']);
|
||||
$existingField->setType($normalized['type']);
|
||||
$existingField->setRequired($normalized['required']);
|
||||
$existingField->setOptions($normalized['options']);
|
||||
$existingField->setDefaultValue($normalized['defaultValue']);
|
||||
$existingField->setOrderIndex($normalized['orderIndex']);
|
||||
$processedIds[$existingField->getId()] = true;
|
||||
} else {
|
||||
// Create new field
|
||||
$cf = new CustomField();
|
||||
$cf->setName($normalized['name']);
|
||||
$cf->setType($normalized['type']);
|
||||
$cf->setRequired($normalized['required']);
|
||||
$cf->setOptions($normalized['options']);
|
||||
$cf->setDefaultValue($normalized['defaultValue']);
|
||||
$cf->setOrderIndex($normalized['orderIndex']);
|
||||
|
||||
match ($category) {
|
||||
ModelCategory::COMPONENT => $cf->setTypeComposant($modelType),
|
||||
ModelCategory::PIECE => $cf->setTypePiece($modelType),
|
||||
ModelCategory::PRODUCT => $cf->setTypeProduct($modelType),
|
||||
};
|
||||
|
||||
$this->em->persist($cf);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove orphaned fields (exist in DB but not in proposed)
|
||||
foreach ($existingFields as $cf) {
|
||||
if (!isset($processedIds[$cf->getId()])) {
|
||||
$this->em->remove($cf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize frontend custom field data to a common shape.
|
||||
*
|
||||
* @return array{name: string, type: string, required: bool, options: ?array, defaultValue: ?string, orderIndex: int}
|
||||
*/
|
||||
private function normalizeCustomFieldData(array $fieldData, int $index): array
|
||||
{
|
||||
// COMPONENT format: {key: "name", value: {type, required, options?, defaultValue?}}
|
||||
if (isset($fieldData['key'], $fieldData['value'])) {
|
||||
$value = $fieldData['value'];
|
||||
|
||||
return [
|
||||
'name' => $fieldData['key'],
|
||||
'type' => $value['type'] ?? 'text',
|
||||
'required' => (bool) ($value['required'] ?? false),
|
||||
'options' => $value['options'] ?? null,
|
||||
'defaultValue' => $value['defaultValue'] ?? null,
|
||||
'orderIndex' => $index,
|
||||
];
|
||||
}
|
||||
|
||||
// PIECE/PRODUCT format: {name, type, required, options?, orderIndex?, defaultValue?}
|
||||
return [
|
||||
'name' => $fieldData['name'] ?? '',
|
||||
'type' => $fieldData['type'] ?? 'text',
|
||||
'required' => (bool) ($fieldData['required'] ?? false),
|
||||
'options' => $fieldData['options'] ?? null,
|
||||
'defaultValue' => $fieldData['defaultValue'] ?? null,
|
||||
'orderIndex' => $fieldData['orderIndex'] ?? $index,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
447
src/Service/Sync/ComposantSyncStrategy.php
Normal file
447
src/Service/Sync/ComposantSyncStrategy.php
Normal file
@@ -0,0 +1,447 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Sync;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\DTO\SyncExecutionResult;
|
||||
use App\DTO\SyncPreviewResult;
|
||||
use App\Entity\Composant;
|
||||
use App\Entity\ComposantPieceSlot;
|
||||
use App\Entity\ComposantProductSlot;
|
||||
use App\Entity\ComposantSubcomponentSlot;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\SkeletonPieceRequirement;
|
||||
use App\Entity\SkeletonProductRequirement;
|
||||
use App\Entity\SkeletonSubcomponentRequirement;
|
||||
use App\Enum\ModelCategory;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
#[AutoconfigureTag('app.sync_strategy')]
|
||||
class ComposantSyncStrategy implements SyncStrategyInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function supports(ModelType $modelType): bool
|
||||
{
|
||||
return ModelCategory::COMPONENT === $modelType->getCategory();
|
||||
}
|
||||
|
||||
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult
|
||||
{
|
||||
$composants = $this->em->getRepository(Composant::class)->findBy(['typeComposant' => $modelType]);
|
||||
|
||||
$proposedPieces = $newStructure['pieces'] ?? [];
|
||||
$proposedProducts = $newStructure['products'] ?? [];
|
||||
$proposedSubcomponents = $newStructure['subcomponents'] ?? [];
|
||||
$proposedCustomFields = $newStructure['customFields'] ?? [];
|
||||
|
||||
$addedPieceSlots = 0;
|
||||
$deletedPieceSlots = 0;
|
||||
$addedProductSlots = 0;
|
||||
$deletedProductSlots = 0;
|
||||
$addedSubSlots = 0;
|
||||
$deletedSubSlots = 0;
|
||||
$addedCfValues = 0;
|
||||
$deletedCfValues = 0;
|
||||
|
||||
// Build proposed typeId lists (one entry per requirement, order = position)
|
||||
$proposedPieceTypeIds = [];
|
||||
foreach ($proposedPieces as $pp) {
|
||||
$proposedPieceTypeIds[] = $pp['typePieceId'];
|
||||
}
|
||||
|
||||
$proposedProductTypeIds = [];
|
||||
foreach ($proposedProducts as $pp) {
|
||||
$proposedProductTypeIds[] = $pp['typeProductId'];
|
||||
}
|
||||
|
||||
$proposedSubTypeIds = [];
|
||||
foreach ($proposedSubcomponents as $ps) {
|
||||
$proposedSubTypeIds[] = $ps['typeComposantId'];
|
||||
}
|
||||
|
||||
// Map proposed custom fields by orderIndex (falls back to array index)
|
||||
$proposedCfByOrder = [];
|
||||
foreach ($proposedCustomFields as $i => $pcf) {
|
||||
$order = $pcf['orderIndex'] ?? $i;
|
||||
$proposedCfByOrder[$order] = $pcf;
|
||||
}
|
||||
|
||||
// Get existing custom fields for this model type
|
||||
$existingFields = $this->em->getRepository(CustomField::class)->findBy(
|
||||
['typeComposant' => $modelType],
|
||||
['orderIndex' => 'ASC']
|
||||
);
|
||||
$existingCfByOrder = [];
|
||||
foreach ($existingFields as $field) {
|
||||
$existingCfByOrder[$field->getOrderIndex()] = $field;
|
||||
}
|
||||
|
||||
// Count custom field additions/deletions (definition-level, affects all composants)
|
||||
$cfAdded = 0;
|
||||
$cfDeleted = 0;
|
||||
foreach ($proposedCfByOrder as $orderIndex => $pcf) {
|
||||
if (!isset($existingCfByOrder[$orderIndex])) {
|
||||
++$cfAdded;
|
||||
}
|
||||
}
|
||||
foreach ($existingCfByOrder as $orderIndex => $ef) {
|
||||
if (!isset($proposedCfByOrder[$orderIndex])) {
|
||||
++$cfDeleted;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($composants as $composant) {
|
||||
// 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]);
|
||||
$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]);
|
||||
$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;
|
||||
$deletedCfValues += $cfDeleted;
|
||||
}
|
||||
|
||||
$itemCount = count($composants);
|
||||
|
||||
return new SyncPreviewResult(
|
||||
modelTypeId: $modelType->getId(),
|
||||
category: 'component',
|
||||
itemCount: $itemCount,
|
||||
additions: [
|
||||
'pieceSlots' => $addedPieceSlots,
|
||||
'productSlots' => $addedProductSlots,
|
||||
'subcomponentSlots' => $addedSubSlots,
|
||||
'customFieldValues' => $addedCfValues,
|
||||
],
|
||||
deletions: [
|
||||
'pieceSlots' => $deletedPieceSlots,
|
||||
'productSlots' => $deletedProductSlots,
|
||||
'subcomponentSlots' => $deletedSubSlots,
|
||||
'customFieldValues' => $deletedCfValues,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult
|
||||
{
|
||||
$composants = $this->em->getRepository(Composant::class)->findBy(['typeComposant' => $modelType]);
|
||||
|
||||
// Load skeleton requirements
|
||||
$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']
|
||||
);
|
||||
|
||||
$addedPieceSlots = 0;
|
||||
$deletedPieceSlots = 0;
|
||||
$addedProductSlots = 0;
|
||||
$deletedProductSlots = 0;
|
||||
$addedSubSlots = 0;
|
||||
$deletedSubSlots = 0;
|
||||
$addedCfValues = 0;
|
||||
$deletedCfValues = 0;
|
||||
$itemsUpdated = 0;
|
||||
|
||||
foreach ($composants as $composant) {
|
||||
$changed = false;
|
||||
|
||||
// --- 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);
|
||||
|
||||
// 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());
|
||||
$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 ($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]);
|
||||
$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);
|
||||
|
||||
// Update matched slots
|
||||
foreach ($matchResult['matched'] as [$slotIdx, $reqIdx]) {
|
||||
$slot = $productSlotEntities[$slotIdx];
|
||||
$req = $productReqs[$reqIdx];
|
||||
if ($slot->getPosition() !== $req->getPosition()) {
|
||||
$slot->setPosition($req->getPosition());
|
||||
$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 ($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 ($matchResult['orphanedSlots'] as $slotIdx) {
|
||||
$slot = $subSlotEntities[$slotIdx];
|
||||
$composant->removeSubcomponentSlot($slot);
|
||||
$this->em->remove($slot);
|
||||
++$deletedSubSlots;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Custom field values ---
|
||||
$existingValues = $this->em->getRepository(CustomFieldValue::class)->findBy([
|
||||
'composant' => $composant,
|
||||
]);
|
||||
|
||||
$existingByFieldId = [];
|
||||
foreach ($existingValues as $cfv) {
|
||||
$existingByFieldId[$cfv->getCustomField()->getId()] = $cfv;
|
||||
}
|
||||
|
||||
// Add missing custom field values
|
||||
foreach ($customFields as $cf) {
|
||||
if (!isset($existingByFieldId[$cf->getId()])) {
|
||||
$cfv = new CustomFieldValue();
|
||||
$cfv->setCustomField($cf);
|
||||
$cfv->setComposant($composant);
|
||||
$cfv->setValue('');
|
||||
$this->em->persist($cfv);
|
||||
++$addedCfValues;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete orphaned custom field values
|
||||
if ($confirmation->confirmDeletions) {
|
||||
$fieldIds = array_map(fn (CustomField $cf) => $cf->getId(), $customFields);
|
||||
foreach ($existingValues as $cfv) {
|
||||
if (!in_array($cfv->getCustomField()->getId(), $fieldIds, true)) {
|
||||
$this->em->remove($cfv);
|
||||
++$deletedCfValues;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$composant->incrementVersion();
|
||||
++$itemsUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return new SyncExecutionResult(
|
||||
itemsUpdated: $itemsUpdated,
|
||||
additions: [
|
||||
'pieceSlots' => $addedPieceSlots,
|
||||
'productSlots' => $addedProductSlots,
|
||||
'subcomponentSlots' => $addedSubSlots,
|
||||
'customFieldValues' => $addedCfValues,
|
||||
],
|
||||
deletions: [
|
||||
'pieceSlots' => $deletedPieceSlots,
|
||||
'productSlots' => $deletedProductSlots,
|
||||
'subcomponentSlots' => $deletedSubSlots,
|
||||
'customFieldValues' => $deletedCfValues,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
309
src/Service/Sync/PieceSyncStrategy.php
Normal file
309
src/Service/Sync/PieceSyncStrategy.php
Normal file
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Sync;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\DTO\SyncExecutionResult;
|
||||
use App\DTO\SyncPreviewResult;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\PieceProductSlot;
|
||||
use App\Entity\SkeletonProductRequirement;
|
||||
use App\Enum\ModelCategory;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
#[AutoconfigureTag('app.sync_strategy')]
|
||||
class PieceSyncStrategy implements SyncStrategyInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function supports(ModelType $modelType): bool
|
||||
{
|
||||
return ModelCategory::PIECE === $modelType->getCategory();
|
||||
}
|
||||
|
||||
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult
|
||||
{
|
||||
$pieces = $this->em->getRepository(Piece::class)->findBy(['typePiece' => $modelType]);
|
||||
|
||||
$proposedProducts = $newStructure['products'] ?? [];
|
||||
$proposedCustomFields = $newStructure['customFields'] ?? [];
|
||||
|
||||
$addedProductSlots = 0;
|
||||
$deletedProductSlots = 0;
|
||||
$addedCfValues = 0;
|
||||
$deletedCfValues = 0;
|
||||
|
||||
// Build proposed typeId list
|
||||
$proposedProductTypeIds = [];
|
||||
foreach ($proposedProducts as $pp) {
|
||||
$proposedProductTypeIds[] = $pp['typeProductId'];
|
||||
}
|
||||
|
||||
// Map proposed custom fields by orderIndex (falls back to array index)
|
||||
$proposedCfByOrder = [];
|
||||
foreach ($proposedCustomFields as $i => $pcf) {
|
||||
$order = $pcf['orderIndex'] ?? $i;
|
||||
$proposedCfByOrder[$order] = $pcf;
|
||||
}
|
||||
|
||||
// Get existing custom fields for this model type
|
||||
$existingFields = $this->em->getRepository(CustomField::class)->findBy(
|
||||
['typePiece' => $modelType],
|
||||
['orderIndex' => 'ASC']
|
||||
);
|
||||
$existingCfByOrder = [];
|
||||
foreach ($existingFields as $field) {
|
||||
$existingCfByOrder[$field->getOrderIndex()] = $field;
|
||||
}
|
||||
|
||||
// Count custom field additions/deletions (definition-level, affects all pieces)
|
||||
$cfAdded = 0;
|
||||
$cfDeleted = 0;
|
||||
foreach ($proposedCfByOrder as $orderIndex => $pcf) {
|
||||
if (!isset($existingCfByOrder[$orderIndex])) {
|
||||
++$cfAdded;
|
||||
}
|
||||
}
|
||||
foreach ($existingCfByOrder as $orderIndex => $ef) {
|
||||
if (!isset($proposedCfByOrder[$orderIndex])) {
|
||||
++$cfDeleted;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pieces as $piece) {
|
||||
// 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;
|
||||
$deletedCfValues += $cfDeleted;
|
||||
}
|
||||
|
||||
$itemCount = count($pieces);
|
||||
|
||||
return new SyncPreviewResult(
|
||||
modelTypeId: $modelType->getId(),
|
||||
category: 'piece',
|
||||
itemCount: $itemCount,
|
||||
additions: [
|
||||
'productSlots' => $addedProductSlots,
|
||||
'customFieldValues' => $addedCfValues,
|
||||
],
|
||||
deletions: [
|
||||
'productSlots' => $deletedProductSlots,
|
||||
'customFieldValues' => $deletedCfValues,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult
|
||||
{
|
||||
$pieces = $this->em->getRepository(Piece::class)->findBy(['typePiece' => $modelType]);
|
||||
|
||||
// Load skeleton requirements
|
||||
$productReqs = $this->em->getRepository(SkeletonProductRequirement::class)->findBy(['modelType' => $modelType], ['position' => 'ASC']);
|
||||
$customFields = $this->em->getRepository(CustomField::class)->findBy(
|
||||
['typePiece' => $modelType],
|
||||
['orderIndex' => 'ASC']
|
||||
);
|
||||
|
||||
$addedProductSlots = 0;
|
||||
$deletedProductSlots = 0;
|
||||
$addedCfValues = 0;
|
||||
$deletedCfValues = 0;
|
||||
$itemsUpdated = 0;
|
||||
|
||||
foreach ($pieces as $piece) {
|
||||
$changed = false;
|
||||
|
||||
// --- Product slots ---
|
||||
$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);
|
||||
|
||||
// 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());
|
||||
$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 ($matchResult['orphanedSlots'] as $slotIdx) {
|
||||
$slot = $productSlotEntities[$slotIdx];
|
||||
$piece->removeProductSlot($slot);
|
||||
$this->em->remove($slot);
|
||||
++$deletedProductSlots;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Custom field values ---
|
||||
$existingValues = $this->em->getRepository(CustomFieldValue::class)->findBy([
|
||||
'piece' => $piece,
|
||||
]);
|
||||
|
||||
$existingByFieldId = [];
|
||||
foreach ($existingValues as $cfv) {
|
||||
$existingByFieldId[$cfv->getCustomField()->getId()] = $cfv;
|
||||
}
|
||||
|
||||
// Add missing custom field values
|
||||
foreach ($customFields as $cf) {
|
||||
if (!isset($existingByFieldId[$cf->getId()])) {
|
||||
$cfv = new CustomFieldValue();
|
||||
$cfv->setCustomField($cf);
|
||||
$cfv->setPiece($piece);
|
||||
$cfv->setValue('');
|
||||
$this->em->persist($cfv);
|
||||
++$addedCfValues;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete orphaned custom field values
|
||||
if ($confirmation->confirmDeletions) {
|
||||
$fieldIds = array_map(fn (CustomField $cf) => $cf->getId(), $customFields);
|
||||
foreach ($existingValues as $cfv) {
|
||||
if (!in_array($cfv->getCustomField()->getId(), $fieldIds, true)) {
|
||||
$this->em->remove($cfv);
|
||||
++$deletedCfValues;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$piece->incrementVersion();
|
||||
++$itemsUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return new SyncExecutionResult(
|
||||
itemsUpdated: $itemsUpdated,
|
||||
additions: [
|
||||
'productSlots' => $addedProductSlots,
|
||||
'customFieldValues' => $addedCfValues,
|
||||
],
|
||||
deletions: [
|
||||
'productSlots' => $deletedProductSlots,
|
||||
'customFieldValues' => $deletedCfValues,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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']),
|
||||
];
|
||||
}
|
||||
}
|
||||
153
src/Service/Sync/ProductSyncStrategy.php
Normal file
153
src/Service/Sync/ProductSyncStrategy.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Sync;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\DTO\SyncExecutionResult;
|
||||
use App\DTO\SyncPreviewResult;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Product;
|
||||
use App\Enum\ModelCategory;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
|
||||
|
||||
#[AutoconfigureTag('app.sync_strategy')]
|
||||
class ProductSyncStrategy implements SyncStrategyInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public function supports(ModelType $modelType): bool
|
||||
{
|
||||
return ModelCategory::PRODUCT === $modelType->getCategory();
|
||||
}
|
||||
|
||||
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult
|
||||
{
|
||||
$products = $this->em->getRepository(Product::class)->findBy(['typeProduct' => $modelType]);
|
||||
$existingFields = $this->em->getRepository(CustomField::class)->findBy(
|
||||
['typeProduct' => $modelType],
|
||||
['orderIndex' => 'ASC']
|
||||
);
|
||||
|
||||
$proposedFields = $newStructure['customFields'] ?? [];
|
||||
|
||||
// Map existing fields by orderIndex
|
||||
$existingByOrder = [];
|
||||
foreach ($existingFields as $field) {
|
||||
$existingByOrder[$field->getOrderIndex()] = $field;
|
||||
}
|
||||
|
||||
// Map proposed fields by orderIndex (falls back to array index)
|
||||
$proposedByOrder = [];
|
||||
foreach ($proposedFields as $i => $pf) {
|
||||
$order = $pf['orderIndex'] ?? $i;
|
||||
$proposedByOrder[$order] = $pf;
|
||||
}
|
||||
|
||||
$addedFields = 0;
|
||||
$deletedFields = 0;
|
||||
$modifiedFields = 0;
|
||||
|
||||
// New fields (in proposed but not in existing)
|
||||
foreach ($proposedByOrder as $orderIndex => $pf) {
|
||||
if (!isset($existingByOrder[$orderIndex])) {
|
||||
++$addedFields;
|
||||
} elseif ($existingByOrder[$orderIndex]->getType() !== ($pf['type'] ?? $pf['value']['type'] ?? null)) {
|
||||
++$modifiedFields;
|
||||
}
|
||||
}
|
||||
|
||||
// Deleted fields (in existing but not in proposed)
|
||||
foreach ($existingByOrder as $orderIndex => $ef) {
|
||||
if (!isset($proposedByOrder[$orderIndex])) {
|
||||
++$deletedFields;
|
||||
}
|
||||
}
|
||||
|
||||
$itemCount = count($products);
|
||||
|
||||
return new SyncPreviewResult(
|
||||
modelTypeId: $modelType->getId(),
|
||||
category: 'product',
|
||||
itemCount: $itemCount,
|
||||
additions: ['customFieldValues' => $addedFields * $itemCount],
|
||||
deletions: ['customFieldValues' => $deletedFields * $itemCount],
|
||||
modifications: ['customFieldValues' => $modifiedFields * $itemCount],
|
||||
);
|
||||
}
|
||||
|
||||
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult
|
||||
{
|
||||
$products = $this->em->getRepository(Product::class)->findBy(['typeProduct' => $modelType]);
|
||||
$customFields = $this->em->getRepository(CustomField::class)->findBy(
|
||||
['typeProduct' => $modelType],
|
||||
['orderIndex' => 'ASC']
|
||||
);
|
||||
|
||||
$addedValues = 0;
|
||||
$deletedValues = 0;
|
||||
$modifiedValues = 0;
|
||||
$itemsUpdated = 0;
|
||||
|
||||
foreach ($products as $product) {
|
||||
$changed = false;
|
||||
|
||||
// Get existing custom field values for this product
|
||||
$existingValues = $this->em->getRepository(CustomFieldValue::class)->findBy([
|
||||
'product' => $product,
|
||||
]);
|
||||
|
||||
// Map existing values by custom field ID
|
||||
$existingByFieldId = [];
|
||||
foreach ($existingValues as $cfv) {
|
||||
$existingByFieldId[$cfv->getCustomField()->getId()] = $cfv;
|
||||
}
|
||||
|
||||
// For each custom field defined on the model type, ensure a value exists
|
||||
foreach ($customFields as $cf) {
|
||||
if (!isset($existingByFieldId[$cf->getId()])) {
|
||||
// Create missing custom field value
|
||||
$cfv = new CustomFieldValue();
|
||||
$cfv->setCustomField($cf);
|
||||
$cfv->setProduct($product);
|
||||
$cfv->setValue('');
|
||||
$this->em->persist($cfv);
|
||||
++$addedValues;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete orphaned values if confirmDeletions
|
||||
if ($confirmation->confirmDeletions) {
|
||||
$fieldIds = array_map(fn (CustomField $cf) => $cf->getId(), $customFields);
|
||||
foreach ($existingValues as $cfv) {
|
||||
if (!in_array($cfv->getCustomField()->getId(), $fieldIds, true)) {
|
||||
$this->em->remove($cfv);
|
||||
++$deletedValues;
|
||||
$changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
$product->incrementVersion();
|
||||
++$itemsUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return new SyncExecutionResult(
|
||||
itemsUpdated: $itemsUpdated,
|
||||
additions: ['customFieldValues' => $addedValues],
|
||||
deletions: ['customFieldValues' => $deletedValues],
|
||||
modifications: ['customFieldValues' => $modifiedValues],
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/Service/Sync/SyncStrategyInterface.php
Normal file
27
src/Service/Sync/SyncStrategyInterface.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Sync;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\DTO\SyncExecutionResult;
|
||||
use App\DTO\SyncPreviewResult;
|
||||
use App\Entity\ModelType;
|
||||
|
||||
interface SyncStrategyInterface
|
||||
{
|
||||
public function supports(ModelType $modelType): bool;
|
||||
|
||||
/**
|
||||
* Compute diff between proposed structure and current items' slots.
|
||||
* Does NOT persist anything.
|
||||
*/
|
||||
public function preview(ModelType $modelType, array $newStructure): SyncPreviewResult;
|
||||
|
||||
/**
|
||||
* Apply sync: compare current skeleton requirements (already persisted)
|
||||
* with items' slots and add/remove as needed.
|
||||
*/
|
||||
public function execute(ModelType $modelType, SyncConfirmation $confirmation): SyncExecutionResult;
|
||||
}
|
||||
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": {
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Entity\MachinePieceLink;
|
||||
use App\Entity\MachineProductLink;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\Piece;
|
||||
use App\Entity\PieceProductSlot;
|
||||
use App\Entity\Product;
|
||||
use App\Entity\Profile;
|
||||
use App\Entity\Site;
|
||||
@@ -236,13 +237,27 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
||||
string $name = 'Custom Field',
|
||||
string $type = 'text',
|
||||
?Machine $machine = null,
|
||||
?ModelType $typeComposant = null,
|
||||
?ModelType $typePiece = null,
|
||||
?ModelType $typeProduct = null,
|
||||
int $orderIndex = 0,
|
||||
): CustomField {
|
||||
$cf = new CustomField();
|
||||
$cf->setName($name);
|
||||
$cf->setType($type);
|
||||
$cf->setOrderIndex($orderIndex);
|
||||
if (null !== $machine) {
|
||||
$cf->setMachine($machine);
|
||||
}
|
||||
if (null !== $typeComposant) {
|
||||
$cf->setTypeComposant($typeComposant);
|
||||
}
|
||||
if (null !== $typePiece) {
|
||||
$cf->setTypePiece($typePiece);
|
||||
}
|
||||
if (null !== $typeProduct) {
|
||||
$cf->setTypeProduct($typeProduct);
|
||||
}
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($cf);
|
||||
@@ -394,6 +409,31 @@ abstract class AbstractApiTestCase extends ApiTestCase
|
||||
return $slot;
|
||||
}
|
||||
|
||||
protected function createPieceProductSlot(
|
||||
Piece $piece,
|
||||
?ModelType $typeProduct = null,
|
||||
?Product $selectedProduct = null,
|
||||
?string $familyCode = null,
|
||||
int $position = 0,
|
||||
): PieceProductSlot {
|
||||
$slot = new PieceProductSlot();
|
||||
$slot->setPiece($piece);
|
||||
$slot->setFamilyCode($familyCode);
|
||||
$slot->setPosition($position);
|
||||
if (null !== $typeProduct) {
|
||||
$slot->setTypeProduct($typeProduct);
|
||||
}
|
||||
if (null !== $selectedProduct) {
|
||||
$slot->setSelectedProduct($selectedProduct);
|
||||
}
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$em->persist($slot);
|
||||
$em->flush();
|
||||
|
||||
return $slot;
|
||||
}
|
||||
|
||||
// ── Assertion helpers ───────────────────────────────────────────
|
||||
|
||||
protected function assertJsonContainsHydraCollection(): void
|
||||
|
||||
267
tests/Api/Controller/ModelTypeSyncControllerTest.php
Normal file
267
tests/Api/Controller/ModelTypeSyncControllerTest.php
Normal file
@@ -0,0 +1,267 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Api\Controller;
|
||||
|
||||
use App\Entity\SkeletonPieceRequirement;
|
||||
use App\Entity\SkeletonProductRequirement;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class ModelTypeSyncControllerTest extends AbstractApiTestCase
|
||||
{
|
||||
// ── sync-preview ────────────────────────────────────────────────
|
||||
|
||||
public function testPreviewReturnsNoImpactWhenNoItems(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync-preview', [
|
||||
'json' => [
|
||||
'structure' => [
|
||||
'pieces' => [],
|
||||
'products' => [],
|
||||
'subcomponents' => [],
|
||||
'customFields' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(0, $data['itemCount']);
|
||||
$this->assertSame($mt->getId(), $data['modelTypeId']);
|
||||
}
|
||||
|
||||
public function testPreviewDetectsNewSlots(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$this->createComposant('C1', $mt);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync-preview', [
|
||||
'json' => [
|
||||
'structure' => [
|
||||
'pieces' => [['typePieceId' => $pieceType->getId(), 'position' => 0]],
|
||||
'products' => [],
|
||||
'subcomponents' => [],
|
||||
'customFields' => [],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(1, $data['itemCount']);
|
||||
$this->assertSame(1, $data['additions']['pieceSlots']);
|
||||
}
|
||||
|
||||
public function testPreview403ForViewer(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync-preview', [
|
||||
'json' => ['structure' => []],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testPreview401ForUnauthenticated(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
|
||||
$client = $this->createUnauthenticatedClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync-preview', [
|
||||
'json' => ['structure' => []],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(401);
|
||||
}
|
||||
|
||||
public function testPreview404ForUnknownModelType(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/nonexistent-id/sync-preview', [
|
||||
'json' => ['structure' => []],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
// ── sync ────────────────────────────────────────────────────────
|
||||
|
||||
public function testSyncAddsSlots(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$this->createComposant('C1', $mt);
|
||||
|
||||
// Add a skeleton requirement (simulates a PATCH that already happened)
|
||||
$em = $this->getEntityManager();
|
||||
$req = new SkeletonPieceRequirement();
|
||||
$req->setModelType($mt);
|
||||
$req->setTypePiece($pieceType);
|
||||
$req->setPosition(0);
|
||||
$em->persist($req);
|
||||
$em->flush();
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => [
|
||||
'confirmDeletions' => false,
|
||||
'confirmTypeChanges' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(1, $data['itemsUpdated']);
|
||||
$this->assertSame(1, $data['additions']['pieceSlots']);
|
||||
}
|
||||
|
||||
public function testSyncDeletesSlotsWithConfirmation(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('C1', $mt);
|
||||
$this->createComposantPieceSlot($composant, $pieceType, null, 1, 0);
|
||||
|
||||
// No skeleton requirements → slot is orphaned
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => [
|
||||
'confirmDeletions' => true,
|
||||
'confirmTypeChanges' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(1, $data['deletions']['pieceSlots']);
|
||||
}
|
||||
|
||||
public function testSyncSkipsDeletionsWithoutConfirmation(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('C1', $mt);
|
||||
$this->createComposantPieceSlot($composant, $pieceType, null, 1, 0);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => [
|
||||
'confirmDeletions' => false,
|
||||
'confirmTypeChanges' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(0, $data['deletions']['pieceSlots']);
|
||||
}
|
||||
|
||||
public function testSync403ForViewer(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
|
||||
$client = $this->createViewerClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => ['confirmDeletions' => false, 'confirmTypeChanges' => false],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(403);
|
||||
}
|
||||
|
||||
public function testSync404ForUnknownModelType(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/nonexistent-id/sync', [
|
||||
'json' => ['confirmDeletions' => false, 'confirmTypeChanges' => false],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testSyncIsIdempotent(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$this->createComposant('C1', $mt);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$req = new SkeletonPieceRequirement();
|
||||
$req->setModelType($mt);
|
||||
$req->setTypePiece($pieceType);
|
||||
$req->setPosition(0);
|
||||
$em->persist($req);
|
||||
$em->flush();
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
// First sync
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => ['confirmDeletions' => false, 'confirmTypeChanges' => false],
|
||||
]);
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data1 = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(1, $data1['itemsUpdated']);
|
||||
|
||||
// Second sync — idempotent, no changes
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => ['confirmDeletions' => false, 'confirmTypeChanges' => false],
|
||||
]);
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data2 = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(0, $data2['itemsUpdated']);
|
||||
}
|
||||
|
||||
public function testSyncWorksForPieceCategory(): void
|
||||
{
|
||||
$mt = $this->createModelType('Piece Cat', 'PC-001', ModelCategory::PIECE);
|
||||
$productType = $this->createModelType('Prod Type', 'PD-001', ModelCategory::PRODUCT);
|
||||
$this->createPiece('P1', 'P1-REF', $mt);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$req = new SkeletonProductRequirement();
|
||||
$req->setModelType($mt);
|
||||
$req->setTypeProduct($productType);
|
||||
$req->setPosition(0);
|
||||
$em->persist($req);
|
||||
$em->flush();
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => ['confirmDeletions' => false, 'confirmTypeChanges' => false],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(1, $data['itemsUpdated']);
|
||||
$this->assertSame(1, $data['additions']['productSlots']);
|
||||
}
|
||||
|
||||
public function testSyncWorksForProductCategory(): void
|
||||
{
|
||||
$mt = $this->createModelType('Prod Cat', 'PD-001', ModelCategory::PRODUCT);
|
||||
$this->createProduct('PR1', 'PR1-REF', $mt);
|
||||
$this->createCustomField('CF1', 'text', typeProduct: $mt, orderIndex: 0);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/model_types/'.$mt->getId().'/sync', [
|
||||
'json' => ['confirmDeletions' => false, 'confirmTypeChanges' => false],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = json_decode($client->getResponse()->getContent(), true);
|
||||
$this->assertSame(1, $data['itemsUpdated']);
|
||||
$this->assertSame(1, $data['additions']['customFieldValues']);
|
||||
}
|
||||
}
|
||||
178
tests/Api/Service/ComposantSyncStrategyTest.php
Normal file
178
tests/Api/Service/ComposantSyncStrategyTest.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Api\Service;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\Entity\SkeletonPieceRequirement;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Service\Sync\ComposantSyncStrategy;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class ComposantSyncStrategyTest extends AbstractApiTestCase
|
||||
{
|
||||
private ComposantSyncStrategy $strategy;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->strategy = static::getContainer()->get(ComposantSyncStrategy::class);
|
||||
}
|
||||
|
||||
public function testSupportsComponentCategory(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$this->assertTrue($this->strategy->supports($mt));
|
||||
}
|
||||
|
||||
public function testPreviewNoImpactWhenNoComposants(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$result = $this->strategy->preview($mt, ['pieces' => [], 'products' => [], 'subcomponents' => [], 'customFields' => []]);
|
||||
$this->assertSame(0, $result->itemCount);
|
||||
$this->assertFalse($result->hasImpact());
|
||||
}
|
||||
|
||||
public function testPreviewDetectsNewPieceSlot(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$this->createComposant('C1', $mt);
|
||||
|
||||
$result = $this->strategy->preview($mt, [
|
||||
'pieces' => [['typePieceId' => $pieceType->getId(), 'position' => 0]],
|
||||
'products' => [],
|
||||
'subcomponents' => [],
|
||||
'customFields' => [],
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $result->itemCount);
|
||||
$this->assertSame(1, $result->additions['pieceSlots']);
|
||||
}
|
||||
|
||||
public function testPreviewDetectsSlotDeletion(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('C1', $mt);
|
||||
$this->createComposantPieceSlot($composant, $pieceType, null, 1, 0);
|
||||
|
||||
$result = $this->strategy->preview($mt, [
|
||||
'pieces' => [],
|
||||
'products' => [],
|
||||
'subcomponents' => [],
|
||||
'customFields' => [],
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $result->deletions['pieceSlots']);
|
||||
}
|
||||
|
||||
public function testPreviewNoImpactWhenSlotsMatch(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('C1', $mt);
|
||||
$this->createComposantPieceSlot($composant, $pieceType, null, 1, 0);
|
||||
|
||||
$result = $this->strategy->preview($mt, [
|
||||
'pieces' => [['typePieceId' => $pieceType->getId(), 'position' => 0]],
|
||||
'products' => [],
|
||||
'subcomponents' => [],
|
||||
'customFields' => [],
|
||||
]);
|
||||
|
||||
$this->assertFalse($result->hasImpact());
|
||||
}
|
||||
|
||||
public function testExecuteAddsMissingSlots(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('C1', $mt);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$req = new SkeletonPieceRequirement();
|
||||
$req->setModelType($mt);
|
||||
$req->setTypePiece($pieceType);
|
||||
$req->setPosition(0);
|
||||
$em->persist($req);
|
||||
$em->flush();
|
||||
|
||||
$result = $this->strategy->execute($mt, new SyncConfirmation());
|
||||
|
||||
$this->assertSame(1, $result->itemsUpdated);
|
||||
$this->assertSame(1, $result->additions['pieceSlots']);
|
||||
|
||||
$em->refresh($composant);
|
||||
$this->assertSame(2, $composant->getVersion());
|
||||
}
|
||||
|
||||
public function testExecutePreservesExistingSelections(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('C1', $mt);
|
||||
$piece = $this->createPiece('P1', 'P1-REF', $pieceType);
|
||||
$slot = $this->createComposantPieceSlot($composant, $pieceType, $piece, 5, 0);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$req = new SkeletonPieceRequirement();
|
||||
$req->setModelType($mt);
|
||||
$req->setTypePiece($pieceType);
|
||||
$req->setPosition(0);
|
||||
$em->persist($req);
|
||||
$em->flush();
|
||||
|
||||
$result = $this->strategy->execute($mt, new SyncConfirmation());
|
||||
|
||||
// No changes — slot already matches
|
||||
$this->assertSame(0, $result->itemsUpdated);
|
||||
|
||||
// Selection and quantity preserved
|
||||
$em->refresh($slot);
|
||||
$this->assertSame($piece->getId(), $slot->getSelectedPiece()->getId());
|
||||
$this->assertSame(5, $slot->getQuantity());
|
||||
}
|
||||
|
||||
public function testExecuteDeletesSlotsOnlyWithConfirmation(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('C1', $mt);
|
||||
$this->createComposantPieceSlot($composant, $pieceType, null, 1, 0);
|
||||
|
||||
// No skeleton requirements -> slot should be deleted
|
||||
$result = $this->strategy->execute($mt, new SyncConfirmation(confirmDeletions: false));
|
||||
$this->assertSame(0, $result->deletions['pieceSlots']);
|
||||
|
||||
// With confirmation
|
||||
$result = $this->strategy->execute($mt, new SyncConfirmation(confirmDeletions: true));
|
||||
$this->assertSame(1, $result->deletions['pieceSlots']);
|
||||
}
|
||||
|
||||
public function testExecuteIsIdempotent(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Piece Type', 'PT-001', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('C1', $mt);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$req = new SkeletonPieceRequirement();
|
||||
$req->setModelType($mt);
|
||||
$req->setTypePiece($pieceType);
|
||||
$req->setPosition(0);
|
||||
$em->persist($req);
|
||||
$em->flush();
|
||||
|
||||
$result1 = $this->strategy->execute($mt, new SyncConfirmation());
|
||||
$this->assertSame(1, $result1->additions['pieceSlots']);
|
||||
|
||||
$em->refresh($composant);
|
||||
$result2 = $this->strategy->execute($mt, new SyncConfirmation());
|
||||
$this->assertSame(0, $result2->itemsUpdated);
|
||||
}
|
||||
}
|
||||
103
tests/Api/Service/PieceSyncStrategyTest.php
Normal file
103
tests/Api/Service/PieceSyncStrategyTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Api\Service;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\Entity\SkeletonProductRequirement;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Service\Sync\PieceSyncStrategy;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class PieceSyncStrategyTest extends AbstractApiTestCase
|
||||
{
|
||||
private PieceSyncStrategy $strategy;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->strategy = static::getContainer()->get(PieceSyncStrategy::class);
|
||||
}
|
||||
|
||||
public function testSupportsPieceCategory(): void
|
||||
{
|
||||
$mt = $this->createModelType('Piece Cat', 'PC-001', ModelCategory::PIECE);
|
||||
$this->assertTrue($this->strategy->supports($mt));
|
||||
}
|
||||
|
||||
public function testPreviewDetectsNewProductSlot(): void
|
||||
{
|
||||
$mt = $this->createModelType('Piece Cat', 'PC-001', ModelCategory::PIECE);
|
||||
$productType = $this->createModelType('Product Type', 'PT-001', ModelCategory::PRODUCT);
|
||||
$this->createPiece('P1', 'P1-REF', $mt);
|
||||
|
||||
$result = $this->strategy->preview($mt, [
|
||||
'products' => [['typeProductId' => $productType->getId(), 'position' => 0]],
|
||||
'customFields' => [],
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $result->itemCount);
|
||||
$this->assertSame(1, $result->additions['productSlots']);
|
||||
}
|
||||
|
||||
public function testExecuteAddsProductSlots(): void
|
||||
{
|
||||
$mt = $this->createModelType('Piece Cat', 'PC-001', ModelCategory::PIECE);
|
||||
$productType = $this->createModelType('Product Type', 'PT-001', ModelCategory::PRODUCT);
|
||||
$piece = $this->createPiece('P1', 'P1-REF', $mt);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$req = new SkeletonProductRequirement();
|
||||
$req->setModelType($mt);
|
||||
$req->setTypeProduct($productType);
|
||||
$req->setPosition(0);
|
||||
$em->persist($req);
|
||||
$em->flush();
|
||||
|
||||
$result = $this->strategy->execute($mt, new SyncConfirmation());
|
||||
|
||||
$this->assertSame(1, $result->itemsUpdated);
|
||||
$this->assertSame(1, $result->additions['productSlots']);
|
||||
|
||||
$em->refresh($piece);
|
||||
$this->assertSame(2, $piece->getVersion());
|
||||
$this->assertCount(1, $piece->getProductSlots());
|
||||
}
|
||||
|
||||
public function testExecuteDeletesWithConfirmation(): void
|
||||
{
|
||||
$mt = $this->createModelType('Piece Cat', 'PC-001', ModelCategory::PIECE);
|
||||
$productType = $this->createModelType('Product Type', 'PT-001', ModelCategory::PRODUCT);
|
||||
$piece = $this->createPiece('P1', 'P1-REF', $mt);
|
||||
$this->createPieceProductSlot($piece, $productType, null, null, 0);
|
||||
|
||||
$result = $this->strategy->execute($mt, new SyncConfirmation(confirmDeletions: true));
|
||||
$this->assertSame(1, $result->deletions['productSlots']);
|
||||
}
|
||||
|
||||
public function testExecuteIsIdempotent(): void
|
||||
{
|
||||
$mt = $this->createModelType('Piece Cat', 'PC-001', ModelCategory::PIECE);
|
||||
$productType = $this->createModelType('Product Type', 'PT-001', ModelCategory::PRODUCT);
|
||||
$piece = $this->createPiece('P1', 'P1-REF', $mt);
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$req = new SkeletonProductRequirement();
|
||||
$req->setModelType($mt);
|
||||
$req->setTypeProduct($productType);
|
||||
$req->setPosition(0);
|
||||
$em->persist($req);
|
||||
$em->flush();
|
||||
|
||||
$result1 = $this->strategy->execute($mt, new SyncConfirmation());
|
||||
$this->assertSame(1, $result1->additions['productSlots']);
|
||||
|
||||
$em->refresh($piece);
|
||||
$result2 = $this->strategy->execute($mt, new SyncConfirmation());
|
||||
$this->assertSame(0, $result2->itemsUpdated);
|
||||
}
|
||||
}
|
||||
92
tests/Api/Service/ProductSyncStrategyTest.php
Normal file
92
tests/Api/Service/ProductSyncStrategyTest.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Api\Service;
|
||||
|
||||
use App\DTO\SyncConfirmation;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Service\Sync\ProductSyncStrategy;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
class ProductSyncStrategyTest extends AbstractApiTestCase
|
||||
{
|
||||
private ProductSyncStrategy $strategy;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->strategy = static::getContainer()->get(ProductSyncStrategy::class);
|
||||
}
|
||||
|
||||
public function testSupportsProductCategory(): void
|
||||
{
|
||||
$mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT);
|
||||
$this->assertTrue($this->strategy->supports($mt));
|
||||
}
|
||||
|
||||
public function testDoesNotSupportComponentCategory(): void
|
||||
{
|
||||
$mt = $this->createModelType('Comp Cat', 'CC-001', ModelCategory::COMPONENT);
|
||||
$this->assertFalse($this->strategy->supports($mt));
|
||||
}
|
||||
|
||||
public function testPreviewNoImpactWhenNoProducts(): void
|
||||
{
|
||||
$mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT);
|
||||
$result = $this->strategy->preview($mt, ['customFields' => []]);
|
||||
$this->assertSame(0, $result->itemCount);
|
||||
$this->assertFalse($result->hasImpact());
|
||||
}
|
||||
|
||||
public function testPreviewDetectsNewCustomField(): void
|
||||
{
|
||||
$mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT);
|
||||
$this->createProduct('P1', 'P1-REF', $mt);
|
||||
|
||||
$result = $this->strategy->preview($mt, [
|
||||
'customFields' => [
|
||||
['name' => 'Weight', 'type' => 'text', 'orderIndex' => 0],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame(1, $result->itemCount);
|
||||
$this->assertSame(1, $result->additions['customFieldValues']);
|
||||
}
|
||||
|
||||
public function testExecuteCreatesCustomFieldValues(): void
|
||||
{
|
||||
$mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT);
|
||||
$product = $this->createProduct('P1', 'P1-REF', $mt);
|
||||
|
||||
// Create a custom field on the model type
|
||||
$this->createCustomField('Weight', 'text', null, null, null, $mt, 0);
|
||||
|
||||
$result = $this->strategy->execute($mt, new SyncConfirmation());
|
||||
|
||||
$this->assertSame(1, $result->itemsUpdated);
|
||||
$this->assertSame(1, $result->additions['customFieldValues']);
|
||||
|
||||
// Verify version incremented
|
||||
$this->getEntityManager()->refresh($product);
|
||||
$this->assertSame(2, $product->getVersion());
|
||||
}
|
||||
|
||||
public function testExecuteIsIdempotent(): void
|
||||
{
|
||||
$mt = $this->createModelType('Product Cat', 'PC-001', ModelCategory::PRODUCT);
|
||||
$product = $this->createProduct('P1', 'P1-REF', $mt);
|
||||
$cf = $this->createCustomField('Weight', 'text', null, null, null, $mt, 0);
|
||||
|
||||
// First execute
|
||||
$result1 = $this->strategy->execute($mt, new SyncConfirmation());
|
||||
$this->assertSame(1, $result1->additions['customFieldValues']);
|
||||
|
||||
// Second execute — no-op
|
||||
$result2 = $this->strategy->execute($mt, new SyncConfirmation());
|
||||
$this->assertSame(0, $result2->itemsUpdated);
|
||||
}
|
||||
}
|
||||
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