Compare commits

..

18 Commits

Author SHA1 Message Date
Matthieu
65fbd38b55 fix(config) : disable rate_limiter config requiring uninstalled component
The mcp_auth rate limiter requires symfony/rate-limiter which is not
installed. Renamed to .disabled until the MCP stack is ready.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:02:37 +01:00
Matthieu
37aa755819 fix(config) : disable uninstalled McpBundle to fix boot crash
McpBundle was registered but symfony/ai-mcp-bundle is not installed,
causing a critical error on boot. Disabled all MCP references:
- bundles.php: removed McpBundle
- mcp.yaml: renamed to mcp.yaml.disabled
- routes.yaml: removed mcp route
- services.yaml: commented McpHeaderAuthenticator, excluded src/Mcp/
- security.yaml: commented mcp firewall and access control
- phpunit.dist.xml: excluded tests/Mcp

All marked with TODO for re-enabling when the package is installed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:01:19 +01:00
Matthieu
98caaa148d feat(mcp) : add McpHeaderAuthenticator with rate limiting
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:07:32 +01:00
Matthieu
523eed927e feat(mcp) : install symfony/mcp-bundle and configure transports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:02:15 +01:00
Matthieu
43bec07bb8 fix(sync) : preserve slot selections when modifying ModelType structure
SkeletonStructureService was deleting all skeleton requirements and
recreating them on every ModelType update. Combined with position-based
matching in sync strategies, any reordering or insertion caused all
existing slots to be orphaned and recreated empty, losing selections.

- SkeletonStructureService: update requirements in-place by matching
  on typeId instead of delete-all/recreate-all
- ComposantSyncStrategy & PieceSyncStrategy: two-pass smart matching
  algorithm (exact typeId+position first, then typeId-only fallback)
  to preserve selectedPiece/selectedComposant/selectedProduct on
  reorder/insertion
- Frontend: check patch result.success before updating local state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:32:14 +01:00
Matthieu
0181f18778 docs(submodule) : update frontend pointer with v1.9.1 changelog
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:53:39 +01:00
Matthieu
8e0acf4896 chore(release) : bump version to 1.9.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:40:45 +01:00
Matthieu
aa8e043c83 fix(submodule) : update frontend pointer with slot selection cache fix
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:32:15 +01:00
Matthieu
b2aff0e414 feat(sync) : add slot selection controllers, custom field sync, and position fallbacks
- Add selectedPieceId support to ComposantPieceSlotController
- Create ComposantProductSlotController and ComposantSubcomponentSlotController
- Add updateCustomFields() to SkeletonStructureService for managing CustomField entities
- Fix position/orderIndex fallback to array index in all 3 sync strategies
- Fix type comparison in ProductSyncStrategy for dual format support
- Update CLAUDE.md with new entities, controllers, and fixtures documentation
- Update frontend submodule with interactive slot selectors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:40:44 +01:00
Matthieu
4072abf7ba feat(sync) : add ModelTypeSyncService orchestrator and controller with tests
Implement the sync orchestrator that delegates to tagged strategies via
AutowireIterator, and the HTTP controller exposing sync-preview and sync
endpoints with transaction wrapping and role-based access control.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:17:57 +01:00
Matthieu
089ca43404 feat(sync) : implement PieceSyncStrategy with tests 2026-03-13 14:07:04 +01:00
Matthieu
f09c7e4782 feat(sync) : implement ComposantSyncStrategy with tests 2026-03-13 14:00:59 +01:00
Matthieu
6a20dcce54 feat(sync) : implement ProductSyncStrategy with tests 2026-03-13 13:54:47 +01:00
Matthieu
6e0be3dbf3 feat(sync) : add DTOs and SyncStrategyInterface
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:47:59 +01:00
Matthieu
f66db3f2f0 test(sync) : extend factories for PieceProductSlot and CustomField with ModelType
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:44:52 +01:00
Matthieu
0864af1439 feat(sync) : migration for PieceProductSlot table and version columns
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:41:56 +01:00
Matthieu
5210e53d73 feat(sync) : add version field and PieceProductSlot entity
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 13:38:13 +01:00
Matthieu
3f07162b94 docs(sync) : add implementation plan for ModelType sync
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:16:19 +01:00
42 changed files with 6462 additions and 108 deletions

View File

@@ -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`

View File

@@ -1 +1 @@
1.9.0
1.9.1

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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:

View 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

View 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

View File

@@ -0,0 +1,6 @@
framework:
rate_limiter:
mcp_auth:
policy: sliding_window
limit: 5
interval: '1 minute'

View File

@@ -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 }

View File

@@ -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>

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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');
}
}

View File

@@ -20,6 +20,7 @@
<testsuites>
<testsuite name="Project Test Suite">
<directory>tests</directory>
<exclude>tests/Mcp</exclude>
</testsuite>
</testsuites>

View File

@@ -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(),
]);
}
}

View 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(),
]);
}
}

View 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(),
]);
}
}

View 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);
}
}

View 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,
) {}
}

View 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,
];
}
}

View 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,
];
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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;
}
}

View 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,
);
}
}

View 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);
}
}

View File

@@ -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,
];
}
}

View 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']),
];
}
}

View 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']),
];
}
}

View 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],
);
}
}

View 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;
}

View File

@@ -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": {

View File

@@ -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

View 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']);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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));
}
}