diff --git a/frontend/app/composables/useMachineCreatePage.ts b/frontend/app/composables/useMachineCreatePage.ts index c9c8286..7ad0e54 100644 --- a/frontend/app/composables/useMachineCreatePage.ts +++ b/frontend/app/composables/useMachineCreatePage.ts @@ -33,6 +33,9 @@ export function useMachineCreatePage() { siteId: '', reference: '', cloneFromMachineId: '', + // 'full' = clone complet (composants/pièces concrets) ; 'structure' = catégories + // uniquement (slots à compléter). + cloneMode: 'full' as 'full' | 'structure', }) // --------------------------------------------------------------------------- @@ -57,6 +60,7 @@ export function useMachineCreatePage() { result = await cloneMachine(newMachine.cloneFromMachineId, { name: newMachine.name, siteId: newMachine.siteId, + mode: newMachine.cloneMode, ...(newMachine.reference ? { reference: newMachine.reference } : {}), }) } else { diff --git a/frontend/app/composables/useMachines.ts b/frontend/app/composables/useMachines.ts index 18e98f8..4a4fd17 100644 --- a/frontend/app/composables/useMachines.ts +++ b/frontend/app/composables/useMachines.ts @@ -169,7 +169,7 @@ export function useMachines() { } } - const cloneMachine = async (sourceId: string, data: { name: string; siteId: string; reference?: string }): Promise => { + const cloneMachine = async (sourceId: string, data: { name: string; siteId: string; reference?: string; mode?: 'full' | 'structure' }): Promise => { loading.value = true try { const result = await post(`/machines/${sourceId}/clone`, data) diff --git a/frontend/app/pages/machines/new.vue b/frontend/app/pages/machines/new.vue index 7a3b9a4..428fdd8 100644 --- a/frontend/app/pages/machines/new.vue +++ b/frontend/app/pages/machines/new.vue @@ -103,6 +103,41 @@ + +
+ +
+ + +
+
+
diff --git a/src/Controller/MachineStructureController.php b/src/Controller/MachineStructureController.php index 7ab2ea7..3e81365 100644 --- a/src/Controller/MachineStructureController.php +++ b/src/Controller/MachineStructureController.php @@ -132,6 +132,14 @@ class MachineStructureController extends AbstractController return $this->json(['success' => false, 'error' => 'Site introuvable.'], 404); } + // Clone mode: 'full' copies concrete components/pieces/products; 'structure' + // only keeps the slots' categories (modelType) with empty concrete entities. + $mode = $payload['mode'] ?? 'full'; + if (!in_array($mode, ['full', 'structure'], true)) { + return $this->json(['success' => false, 'error' => 'mode invalide (valeurs autorisées : full, structure).'], 400); + } + $structureOnly = 'structure' === $mode; + // Create new machine $newMachine = new Machine(); $newMachine->setName($payload['name']); @@ -156,13 +164,13 @@ class MachineStructureController extends AbstractController $this->cloneCustomFields($source, $newMachine); // Copy component links (preserving hierarchy) - $componentLinkMap = $this->cloneComponentLinks($source, $newMachine); + $componentLinkMap = $this->cloneComponentLinks($source, $newMachine, $structureOnly); // Copy piece links - $pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap); + $pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap, $structureOnly); // Copy product links - $this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap); + $this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap, $structureOnly); $this->entityManager->flush(); @@ -215,7 +223,7 @@ class MachineStructureController extends AbstractController /** * @return array Map of old link ID → new link */ - private function cloneComponentLinks(Machine $source, Machine $target): array + private function cloneComponentLinks(Machine $source, Machine $target, bool $structureOnly = false): array { $sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']); $linkMap = []; @@ -224,6 +232,16 @@ class MachineStructureController extends AbstractController foreach ($sourceLinks as $link) { $newLink = new MachineComponentLink(); $newLink->setMachine($target); + + if ($structureOnly) { + // Keep only the slot category; leave the concrete component empty. + $newLink->setModelType($link->getModelType() ?? $link->getComposant()?->getTypeComposant()); + $this->entityManager->persist($newLink); + $linkMap[$link->getId()] = $newLink; + + continue; + } + $newLink->setComposant($link->getComposant()); $newLink->setNameOverride($link->getNameOverride()); $newLink->setReferenceOverride($link->getReferenceOverride()); @@ -259,7 +277,7 @@ class MachineStructureController extends AbstractController * * @return array Map of old link ID → new link */ - private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array + private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap, bool $structureOnly = false): array { $sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']); $linkMap = []; @@ -267,17 +285,27 @@ class MachineStructureController extends AbstractController foreach ($sourceLinks as $link) { $newLink = new MachinePieceLink(); $newLink->setMachine($target); - $newLink->setPiece($link->getPiece()); - $newLink->setNameOverride($link->getNameOverride()); - $newLink->setReferenceOverride($link->getReferenceOverride()); - $newLink->setPrixOverride($link->getPrixOverride()); - $newLink->setQuantity($link->getQuantity()); $parent = $link->getParentLink(); if ($parent && isset($componentLinkMap[$parent->getId()])) { $newLink->setParentLink($componentLinkMap[$parent->getId()]); } + if ($structureOnly) { + // Keep only the slot category; leave the concrete piece empty. + $newLink->setModelType($link->getModelType() ?? $link->getPiece()?->getTypePiece()); + $this->entityManager->persist($newLink); + $linkMap[$link->getId()] = $newLink; + + continue; + } + + $newLink->setPiece($link->getPiece()); + $newLink->setNameOverride($link->getNameOverride()); + $newLink->setReferenceOverride($link->getReferenceOverride()); + $newLink->setPrixOverride($link->getPrixOverride()); + $newLink->setQuantity($link->getQuantity()); + $this->entityManager->persist($newLink); foreach ($link->getContextFieldValues() as $cfv) { @@ -305,6 +333,7 @@ class MachineStructureController extends AbstractController Machine $target, array $componentLinkMap, array $pieceLinkMap, + bool $structureOnly = false, ): void { $sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']); $linkMap = []; @@ -313,7 +342,13 @@ class MachineStructureController extends AbstractController foreach ($sourceLinks as $link) { $newLink = new MachineProductLink(); $newLink->setMachine($target); - $newLink->setProduct($link->getProduct()); + + if ($structureOnly) { + // Keep only the slot category; leave the concrete product empty. + $newLink->setModelType($link->getModelType() ?? $link->getProduct()?->getTypeProduct()); + } else { + $newLink->setProduct($link->getProduct()); + } $parentComponent = $link->getParentComponentLink(); if ($parentComponent && isset($componentLinkMap[$parentComponent->getId()])) { @@ -774,7 +809,7 @@ class MachineStructureController extends AbstractController $pieces = []; foreach ($composant->getPieceSlots() as $slot) { $selectedPiece = $this->ensurePieceExists($slot->getSelectedPiece()); - $pieceData = [ + $pieceData = [ 'slotId' => $slot->getId(), 'typePieceId' => $slot->getTypePiece()?->getId(), 'typePiece' => $this->normalizeModelType($slot->getTypePiece()), @@ -824,6 +859,7 @@ class MachineStructureController extends AbstractController if (null === $piece) { return null; } + try { $this->entityManager->initializeObject($piece); @@ -844,6 +880,7 @@ class MachineStructureController extends AbstractController if (null === $cf) { return null; } + try { $this->entityManager->initializeObject($cf); diff --git a/tests/Api/Entity/MachineContextCustomFieldTest.php b/tests/Api/Entity/MachineContextCustomFieldTest.php index 68f634b..dde84c6 100644 --- a/tests/Api/Entity/MachineContextCustomFieldTest.php +++ b/tests/Api/Entity/MachineContextCustomFieldTest.php @@ -308,4 +308,88 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase $this->assertCount(1, $sourceLink['contextCustomFieldValues']); $this->assertSame('1500', $sourceLink['contextCustomFieldValues'][0]['value']); } + + public function testCloneMachineStructureModeKeepsCategoriesWithoutConcreteEntities(): void + { + $client = $this->createGestionnaireClient(); + + $site = $this->createSite('Site Structure'); + $compType = $this->createModelType('Motor Struct', 'MOTST', ModelCategory::COMPONENT); + $pieceType = $this->createModelType('Bearing Struct', 'BRGST', ModelCategory::PIECE); + $contextField = $this->createCustomField( + name: 'RPM Struct', + type: 'number', + typeComposant: $compType, + machineContextOnly: true, + ); + + $source = $this->createMachine('Source Struct Machine', $site); + $composant = $this->createComposant('Motor ST', 'MOTST-001', $compType); + $componentLink = $this->createMachineComponentLink($source, $composant); + $piece = $this->createPiece('Bearing ST', 'BRGST-001', $pieceType); + $this->createMachinePieceLink($source, $piece, $componentLink); + + $this->createCustomFieldValue( + customField: $contextField, + value: '4200', + composant: $composant, + machineComponentLink: $componentLink, + ); + + $response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [ + 'json' => [ + 'name' => 'Cloned Struct Machine', + 'siteId' => $site->getId(), + 'mode' => 'structure', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $data = $response->toArray(); + + // Component slot: category preserved, concrete component dropped, no context values. + $clonedComponent = $data['componentLinks'][0] ?? null; + $this->assertNotNull($clonedComponent, 'Structure clone should expose the component slot'); + $this->assertTrue($clonedComponent['pendingEntity']); + $this->assertNull($clonedComponent['composantId']); + $this->assertSame($compType->getId(), $clonedComponent['modelTypeId']); + $this->assertCount(0, $clonedComponent['contextCustomFieldValues']); + + // Piece slot: category preserved, concrete piece dropped. + $clonedPiece = $data['pieceLinks'][0] ?? null; + $this->assertNotNull($clonedPiece, 'Structure clone should expose the piece slot'); + $this->assertTrue($clonedPiece['pendingEntity']); + $this->assertNull($clonedPiece['pieceId']); + $this->assertSame($pieceType->getId(), $clonedPiece['modelTypeId']); + + // Source machine stays intact (still has its concrete component). + $sourceData = $client->request('GET', '/api/machines/'.$source->getId().'/structure')->toArray(); + $this->assertFalse($sourceData['componentLinks'][0]['pendingEntity']); + $this->assertSame($composant->getId(), $sourceData['componentLinks'][0]['composantId']); + } + + public function testCloneMachineFullModeStillCopiesConcreteEntities(): void + { + $client = $this->createGestionnaireClient(); + + $site = $this->createSite('Site Full'); + $compType = $this->createModelType('Motor Full', 'MOTFL', ModelCategory::COMPONENT); + $source = $this->createMachine('Source Full Machine', $site); + $composant = $this->createComposant('Motor FL', 'MOTFL-001', $compType); + $this->createMachineComponentLink($source, $composant); + + $response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [ + 'json' => [ + 'name' => 'Cloned Full Machine', + 'siteId' => $site->getId(), + 'mode' => 'full', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $clonedComponent = $response->toArray()['componentLinks'][0] ?? null; + $this->assertNotNull($clonedComponent); + $this->assertFalse($clonedComponent['pendingEntity']); + $this->assertSame($composant->getId(), $clonedComponent['composantId']); + } }