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