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:
Matthieu
2026-06-15 11:16:02 +02:00
parent b775718df6
commit 494298f981
5 changed files with 173 additions and 13 deletions
@@ -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 {
+1 -1
View File
@@ -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)
+35
View File
@@ -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">
+49 -12
View File
@@ -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']);
}
}