feat(machines) : ajoute le clonage par catégorie (structure seule)
Nouveau mode de clonage de machine via le paramètre `mode` de
POST /api/machines/{id}/clone :
- mode "full" (défaut) : comportement inchangé (clone complet)
- mode "structure" : ne recopie que les catégories des slots
(modelType), composant/pièce/produit concrets laissés vides
(slots à compléter), sans overrides ni custom field values
Front : sélecteur de mode dans la page de création de machine,
visible uniquement quand une machine source est choisie.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -169,7 +169,7 @@ export function useMachines() {
|
||||
}
|
||||
}
|
||||
|
||||
const cloneMachine = async (sourceId: string, data: { name: string; siteId: string; reference?: string }): Promise<ApiResponse> => {
|
||||
const cloneMachine = async (sourceId: string, data: { name: string; siteId: string; reference?: string; mode?: 'full' | 'structure' }): Promise<ApiResponse> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await post(`/machines/${sourceId}/clone`, data)
|
||||
|
||||
@@ -103,6 +103,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clone mode (visible only when a source machine is selected) -->
|
||||
<div v-if="c.newMachine.cloneFromMachineId" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Mode de clonage</span>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:gap-6">
|
||||
<label class="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="c.newMachine.cloneMode"
|
||||
type="radio"
|
||||
value="full"
|
||||
class="radio radio-primary radio-sm mt-0.5"
|
||||
:disabled="!canEdit"
|
||||
>
|
||||
<span class="text-sm">
|
||||
Tout cloner
|
||||
<span class="block text-xs text-gray-500">Structure + composants et pièces assignés</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="c.newMachine.cloneMode"
|
||||
type="radio"
|
||||
value="structure"
|
||||
class="radio radio-primary radio-sm mt-0.5"
|
||||
:disabled="!canEdit"
|
||||
>
|
||||
<span class="text-sm">
|
||||
Structure seule
|
||||
<span class="block text-xs text-gray-500">Catégories uniquement, slots à compléter</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-base-200">
|
||||
<NuxtLink to="/machines" class="btn btn-outline btn-sm md:btn-md">
|
||||
|
||||
@@ -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<string, MachineComponentLink> 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<string, MachinePieceLink> 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);
|
||||
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user