Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7546c8e8a | |||
| 494298f981 |
@@ -33,6 +33,9 @@ export function useMachineCreatePage() {
|
|||||||
siteId: '',
|
siteId: '',
|
||||||
reference: '',
|
reference: '',
|
||||||
cloneFromMachineId: '',
|
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, {
|
result = await cloneMachine(newMachine.cloneFromMachineId, {
|
||||||
name: newMachine.name,
|
name: newMachine.name,
|
||||||
siteId: newMachine.siteId,
|
siteId: newMachine.siteId,
|
||||||
|
mode: newMachine.cloneMode,
|
||||||
...(newMachine.reference ? { reference: newMachine.reference } : {}),
|
...(newMachine.reference ? { reference: newMachine.reference } : {}),
|
||||||
})
|
})
|
||||||
} else {
|
} 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
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await post(`/machines/${sourceId}/clone`, data)
|
const result = await post(`/machines/${sourceId}/clone`, data)
|
||||||
|
|||||||
@@ -103,6 +103,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Actions -->
|
||||||
<div class="flex justify-end gap-3 pt-4 border-t border-base-200">
|
<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">
|
<NuxtLink to="/machines" class="btn btn-outline btn-sm md:btn-md">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\MachineStructure;
|
||||||
|
|
||||||
|
use App\Entity\CustomField;
|
||||||
|
use App\Entity\CustomFieldValue;
|
||||||
|
use App\Entity\Machine;
|
||||||
|
use App\Entity\MachineComponentLink;
|
||||||
|
use App\Entity\MachineConstructeurLink;
|
||||||
|
use App\Entity\MachinePieceLink;
|
||||||
|
use App\Entity\MachineProductLink;
|
||||||
|
use App\Entity\Site;
|
||||||
|
use App\Repository\MachineComponentLinkRepository;
|
||||||
|
use App\Repository\MachinePieceLinkRepository;
|
||||||
|
use App\Repository\MachineProductLinkRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clones a machine and its full structure (constructeur links, custom fields,
|
||||||
|
* component/piece/product links preserving the hierarchy). Extracted from
|
||||||
|
* MachineStructureController.
|
||||||
|
*/
|
||||||
|
class MachineCloner
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
|
||||||
|
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
|
||||||
|
private readonly MachineProductLinkRepository $machineProductLinkRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @throws MachineStructureException on invalid payload / missing site
|
||||||
|
*/
|
||||||
|
public function clone(Machine $source, array $payload): Machine
|
||||||
|
{
|
||||||
|
if (empty($payload['name']) || empty($payload['siteId'])) {
|
||||||
|
throw new MachineStructureException('name et siteId sont requis.', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$site = $this->entityManager->getRepository(Site::class)->find($payload['siteId']);
|
||||||
|
if (!$site) {
|
||||||
|
throw new MachineStructureException('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)) {
|
||||||
|
throw new MachineStructureException('mode invalide (valeurs autorisées : full, structure).', 400);
|
||||||
|
}
|
||||||
|
$structureOnly = 'structure' === $mode;
|
||||||
|
|
||||||
|
// Create new machine
|
||||||
|
$newMachine = new Machine();
|
||||||
|
$newMachine->setName($payload['name']);
|
||||||
|
$newMachine->setSite($site);
|
||||||
|
if (!empty($payload['reference'])) {
|
||||||
|
$newMachine->setReference($payload['reference']);
|
||||||
|
}
|
||||||
|
$newMachine->setPrix($source->getPrix());
|
||||||
|
|
||||||
|
// Copy constructeur links
|
||||||
|
foreach ($source->getConstructeurLinks() as $link) {
|
||||||
|
$newLink = new MachineConstructeurLink();
|
||||||
|
$newLink->setMachine($newMachine);
|
||||||
|
$newLink->setConstructeur($link->getConstructeur());
|
||||||
|
$newLink->setSupplierReference($link->getSupplierReference());
|
||||||
|
$this->entityManager->persist($newLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->persist($newMachine);
|
||||||
|
|
||||||
|
// Copy custom fields and values
|
||||||
|
$this->cloneCustomFields($source, $newMachine);
|
||||||
|
|
||||||
|
// Copy component links (preserving hierarchy)
|
||||||
|
$componentLinkMap = $this->cloneComponentLinks($source, $newMachine, $structureOnly);
|
||||||
|
|
||||||
|
// Copy piece links
|
||||||
|
$pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap, $structureOnly);
|
||||||
|
|
||||||
|
// Copy product links
|
||||||
|
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap, $structureOnly);
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $newMachine;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cloneCustomFields(Machine $source, Machine $target): void
|
||||||
|
{
|
||||||
|
$cfMap = [];
|
||||||
|
|
||||||
|
foreach ($source->getCustomFields() as $cf) {
|
||||||
|
$newCf = new CustomField();
|
||||||
|
$newCf->setName($cf->getName());
|
||||||
|
$newCf->setType($cf->getType());
|
||||||
|
$newCf->setRequired($cf->isRequired());
|
||||||
|
$newCf->setDefaultValue($cf->getDefaultValue());
|
||||||
|
$newCf->setOptions($cf->getOptions());
|
||||||
|
$newCf->setOrderIndex($cf->getOrderIndex());
|
||||||
|
$newCf->setMachineContextOnly($cf->isMachineContextOnly());
|
||||||
|
$newCf->setMachine($target);
|
||||||
|
$this->entityManager->persist($newCf);
|
||||||
|
|
||||||
|
$cfMap[$cf->getId()] = $newCf;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($source->getCustomFieldValues() as $cfv) {
|
||||||
|
$originalCf = $cfv->getCustomField();
|
||||||
|
$newCf = $cfMap[$originalCf->getId()] ?? null;
|
||||||
|
if (!$newCf) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newValue = new CustomFieldValue();
|
||||||
|
$newValue->setMachine($target);
|
||||||
|
$newValue->setCustomField($newCf);
|
||||||
|
$newValue->setValue($cfv->getValue());
|
||||||
|
$this->entityManager->persist($newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, MachineComponentLink> Map of old link ID → new link
|
||||||
|
*/
|
||||||
|
private function cloneComponentLinks(Machine $source, Machine $target, bool $structureOnly = false): array
|
||||||
|
{
|
||||||
|
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||||
|
$linkMap = [];
|
||||||
|
|
||||||
|
// First pass: create all links without parent relationships
|
||||||
|
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());
|
||||||
|
$newLink->setPrixOverride($link->getPrixOverride());
|
||||||
|
$this->entityManager->persist($newLink);
|
||||||
|
|
||||||
|
foreach ($link->getContextFieldValues() as $cfv) {
|
||||||
|
$newValue = new CustomFieldValue();
|
||||||
|
$newValue->setCustomField($cfv->getCustomField());
|
||||||
|
$newValue->setValue($cfv->getValue());
|
||||||
|
$newValue->setMachineComponentLink($newLink);
|
||||||
|
$newValue->setComposant($newLink->getComposant());
|
||||||
|
$this->entityManager->persist($newValue);
|
||||||
|
$newLink->getContextFieldValues()->add($newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkMap[$link->getId()] = $newLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: set parent relationships
|
||||||
|
foreach ($sourceLinks as $link) {
|
||||||
|
$parent = $link->getParentLink();
|
||||||
|
if ($parent && isset($linkMap[$parent->getId()])) {
|
||||||
|
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $linkMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, MachineComponentLink> $componentLinkMap
|
||||||
|
*
|
||||||
|
* @return array<string, MachinePieceLink> Map of old link ID → new link
|
||||||
|
*/
|
||||||
|
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap, bool $structureOnly = false): array
|
||||||
|
{
|
||||||
|
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||||
|
$linkMap = [];
|
||||||
|
|
||||||
|
foreach ($sourceLinks as $link) {
|
||||||
|
$newLink = new MachinePieceLink();
|
||||||
|
$newLink->setMachine($target);
|
||||||
|
|
||||||
|
$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) {
|
||||||
|
$newValue = new CustomFieldValue();
|
||||||
|
$newValue->setCustomField($cfv->getCustomField());
|
||||||
|
$newValue->setValue($cfv->getValue());
|
||||||
|
$newValue->setMachinePieceLink($newLink);
|
||||||
|
$newValue->setPiece($newLink->getPiece());
|
||||||
|
$this->entityManager->persist($newValue);
|
||||||
|
$newLink->getContextFieldValues()->add($newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
$linkMap[$link->getId()] = $newLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $linkMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, MachineComponentLink> $componentLinkMap
|
||||||
|
* @param array<string, MachinePieceLink> $pieceLinkMap
|
||||||
|
*/
|
||||||
|
private function cloneProductLinks(
|
||||||
|
Machine $source,
|
||||||
|
Machine $target,
|
||||||
|
array $componentLinkMap,
|
||||||
|
array $pieceLinkMap,
|
||||||
|
bool $structureOnly = false,
|
||||||
|
): void {
|
||||||
|
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||||
|
$linkMap = [];
|
||||||
|
|
||||||
|
// First pass: create all links
|
||||||
|
foreach ($sourceLinks as $link) {
|
||||||
|
$newLink = new MachineProductLink();
|
||||||
|
$newLink->setMachine($target);
|
||||||
|
|
||||||
|
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()])) {
|
||||||
|
$newLink->setParentComponentLink($componentLinkMap[$parentComponent->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$parentPiece = $link->getParentPieceLink();
|
||||||
|
if ($parentPiece && isset($pieceLinkMap[$parentPiece->getId()])) {
|
||||||
|
$newLink->setParentPieceLink($pieceLinkMap[$parentPiece->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->persist($newLink);
|
||||||
|
$linkMap[$link->getId()] = $newLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: set parent product link relationships
|
||||||
|
foreach ($sourceLinks as $link) {
|
||||||
|
$parent = $link->getParentLink();
|
||||||
|
if ($parent && isset($linkMap[$parent->getId()])) {
|
||||||
|
$linkMap[$link->getId()]->setParentLink($linkMap[$parent->getId()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\MachineStructure;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain error raised while building or mutating a machine structure.
|
||||||
|
* Carries the HTTP status the controller should surface so the services
|
||||||
|
* stay decoupled from the HTTP layer.
|
||||||
|
*/
|
||||||
|
class MachineStructureException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message,
|
||||||
|
private readonly int $statusCode = 400,
|
||||||
|
) {
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStatusCode(): int
|
||||||
|
{
|
||||||
|
return $this->statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,559 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\MachineStructure;
|
||||||
|
|
||||||
|
use App\Entity\Composant;
|
||||||
|
use App\Entity\Constructeur;
|
||||||
|
use App\Entity\CustomField;
|
||||||
|
use App\Entity\CustomFieldValue;
|
||||||
|
use App\Entity\Machine;
|
||||||
|
use App\Entity\MachineComponentLink;
|
||||||
|
use App\Entity\MachinePieceLink;
|
||||||
|
use App\Entity\MachineProductLink;
|
||||||
|
use App\Entity\ModelType;
|
||||||
|
use App\Entity\Piece;
|
||||||
|
use App\Entity\Product;
|
||||||
|
use App\Repository\MachineComponentLinkRepository;
|
||||||
|
use App\Repository\MachinePieceLinkRepository;
|
||||||
|
use App\Repository\MachineProductLinkRepository;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\EntityNotFoundException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the JSON structure payload for a machine (machine + component/piece/
|
||||||
|
* product links, nested hierarchy, custom fields). Extracted from
|
||||||
|
* MachineStructureController so the GET, PATCH and clone routes share one
|
||||||
|
* source of truth for the response shape.
|
||||||
|
*/
|
||||||
|
class MachineStructureNormalizer
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
|
||||||
|
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
|
||||||
|
private readonly MachineProductLinkRepository $machineProductLinkRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the machine's links and returns the full normalized structure.
|
||||||
|
*/
|
||||||
|
public function normalize(Machine $machine): array
|
||||||
|
{
|
||||||
|
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
|
||||||
|
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
|
||||||
|
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']);
|
||||||
|
|
||||||
|
return $this->normalizeStructureResponse($machine, $componentLinks, $pieceLinks, $productLinks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeStructureResponse(
|
||||||
|
Machine $machine,
|
||||||
|
array $componentLinks,
|
||||||
|
array $pieceLinks,
|
||||||
|
array $productLinks,
|
||||||
|
): array {
|
||||||
|
$normalizedComponentLinks = $this->normalizeComponentLinks($componentLinks);
|
||||||
|
$componentIndex = $this->indexNormalizedLinks($normalizedComponentLinks);
|
||||||
|
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
|
||||||
|
|
||||||
|
$childIds = [];
|
||||||
|
foreach ($normalizedComponentLinks as $link) {
|
||||||
|
$parentId = $link['parentComponentLinkId'] ?? null;
|
||||||
|
if ($parentId && isset($componentIndex[$parentId])) {
|
||||||
|
$componentIndex[$parentId]['childLinks'][] = $link;
|
||||||
|
$childIds[$link['id']] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
|
||||||
|
|
||||||
|
$rootComponents = array_filter(
|
||||||
|
$componentIndex,
|
||||||
|
static fn (array $link) => !isset($childIds[$link['id']]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'machine' => $this->normalizeMachine($machine),
|
||||||
|
'componentLinks' => array_values($rootComponents),
|
||||||
|
'pieceLinks' => $normalizedPieceLinks,
|
||||||
|
'productLinks' => $this->normalizeProductLinks($productLinks),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attachPiecesToComponents(array &$componentIndex, array $pieceLinks): void
|
||||||
|
{
|
||||||
|
foreach ($pieceLinks as $pieceLink) {
|
||||||
|
$parentId = $pieceLink['parentComponentLinkId'] ?? null;
|
||||||
|
if ($parentId && isset($componentIndex[$parentId])) {
|
||||||
|
$componentIndex[$parentId]['pieceLinks'][] = $pieceLink;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($componentIndex as &$component) {
|
||||||
|
if (!empty($component['childLinks'])) {
|
||||||
|
$this->attachPiecesToChildComponents($component['childLinks'], $pieceLinks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attachPiecesToChildComponents(array &$childLinks, array $pieceLinks): void
|
||||||
|
{
|
||||||
|
foreach ($childLinks as &$child) {
|
||||||
|
$childId = $child['id'] ?? $child['linkId'] ?? null;
|
||||||
|
if ($childId) {
|
||||||
|
foreach ($pieceLinks as $pieceLink) {
|
||||||
|
$parentId = $pieceLink['parentComponentLinkId'] ?? null;
|
||||||
|
if ($parentId === $childId) {
|
||||||
|
$child['pieceLinks'][] = $pieceLink;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($child['childLinks'])) {
|
||||||
|
$this->attachPiecesToChildComponents($child['childLinks'], $pieceLinks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeMachine(Machine $machine): array
|
||||||
|
{
|
||||||
|
$site = $machine->getSite();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $machine->getId(),
|
||||||
|
'name' => $machine->getName(),
|
||||||
|
'reference' => $machine->getReference(),
|
||||||
|
'prix' => $machine->getPrix(),
|
||||||
|
'siteId' => $site->getId(),
|
||||||
|
'site' => [
|
||||||
|
'id' => $site->getId(),
|
||||||
|
'name' => $site->getName(),
|
||||||
|
],
|
||||||
|
'constructeurs' => $this->normalizeConstructeurLinks($machine->getConstructeurLinks()),
|
||||||
|
'customFields' => $this->normalizeCustomFields($machine->getCustomFields()),
|
||||||
|
'documents' => null,
|
||||||
|
'customFieldValues' => $this->normalizeCustomFieldValues($machine->getCustomFieldValues()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeCustomFields(Collection $customFields): array
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
foreach ($customFields as $customField) {
|
||||||
|
if (!$customField instanceof CustomField) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items[] = [
|
||||||
|
'id' => $customField->getId(),
|
||||||
|
'name' => $customField->getName(),
|
||||||
|
'type' => $customField->getType(),
|
||||||
|
'required' => $customField->isRequired(),
|
||||||
|
'options' => $customField->getOptions(),
|
||||||
|
'defaultValue' => $customField->getDefaultValue(),
|
||||||
|
'orderIndex' => $customField->getOrderIndex(),
|
||||||
|
'machineContextOnly' => $customField->isMachineContextOnly(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeComponentLinks(array $links): array
|
||||||
|
{
|
||||||
|
return array_map(function (MachineComponentLink $link): array {
|
||||||
|
$composant = $link->getComposant();
|
||||||
|
$modelType = $link->getModelType();
|
||||||
|
$parentLink = $link->getParentLink();
|
||||||
|
$type = $composant?->getTypeComposant();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $link->getId(),
|
||||||
|
'linkId' => $link->getId(),
|
||||||
|
'machineId' => $link->getMachine()->getId(),
|
||||||
|
'composantId' => $composant?->getId(),
|
||||||
|
'composant' => $composant ? $this->normalizeComposant($composant) : null,
|
||||||
|
'modelTypeId' => $modelType?->getId(),
|
||||||
|
'modelType' => $modelType ? $this->normalizeModelType($modelType) : null,
|
||||||
|
'pendingEntity' => null === $composant,
|
||||||
|
'parentLinkId' => $parentLink?->getId(),
|
||||||
|
'parentComponentLinkId' => $parentLink?->getId(),
|
||||||
|
'parentComponentId' => $parentLink?->getComposant()?->getId(),
|
||||||
|
'overrides' => $this->normalizeOverrides($link),
|
||||||
|
'childLinks' => [],
|
||||||
|
'pieceLinks' => [],
|
||||||
|
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [],
|
||||||
|
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
|
||||||
|
];
|
||||||
|
}, $links);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizePieceLinks(array $links): array
|
||||||
|
{
|
||||||
|
return array_map(function (MachinePieceLink $link): array {
|
||||||
|
$piece = $this->ensurePieceExists($link->getPiece());
|
||||||
|
$modelType = $link->getModelType();
|
||||||
|
$parentLink = $link->getParentLink();
|
||||||
|
$type = $piece?->getTypePiece();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $link->getId(),
|
||||||
|
'linkId' => $link->getId(),
|
||||||
|
'machineId' => $link->getMachine()->getId(),
|
||||||
|
'pieceId' => $piece?->getId(),
|
||||||
|
'piece' => $piece ? $this->normalizePiece($piece) : null,
|
||||||
|
'modelTypeId' => $modelType?->getId(),
|
||||||
|
'modelType' => $modelType ? $this->normalizeModelType($modelType) : null,
|
||||||
|
'pendingEntity' => null === $piece,
|
||||||
|
'parentLinkId' => $parentLink?->getId(),
|
||||||
|
'parentComponentLinkId' => $parentLink?->getId(),
|
||||||
|
'parentComponentId' => $parentLink?->getComposant()?->getId(),
|
||||||
|
'overrides' => $this->normalizeOverrides($link),
|
||||||
|
'quantity' => $piece ? $this->resolvePieceQuantity($link) : 1,
|
||||||
|
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [],
|
||||||
|
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
|
||||||
|
];
|
||||||
|
}, $links);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePieceQuantity(MachinePieceLink $link): int
|
||||||
|
{
|
||||||
|
$parentLink = $link->getParentLink();
|
||||||
|
$piece = $this->ensurePieceExists($link->getPiece());
|
||||||
|
|
||||||
|
if (!$parentLink || !$piece) {
|
||||||
|
return $link->getQuantity();
|
||||||
|
}
|
||||||
|
|
||||||
|
$composant = $parentLink->getComposant();
|
||||||
|
if (!$composant) {
|
||||||
|
return $link->getQuantity();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($composant->getPieceSlots() as $slot) {
|
||||||
|
$selected = $this->ensurePieceExists($slot->getSelectedPiece());
|
||||||
|
if ($selected?->getId() === $piece->getId()) {
|
||||||
|
return $slot->getQuantity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $link->getQuantity();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeProductLinks(array $links): array
|
||||||
|
{
|
||||||
|
return array_map(function (MachineProductLink $link): array {
|
||||||
|
$product = $link->getProduct();
|
||||||
|
$modelType = $link->getModelType();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $link->getId(),
|
||||||
|
'linkId' => $link->getId(),
|
||||||
|
'machineId' => $link->getMachine()->getId(),
|
||||||
|
'productId' => $product?->getId(),
|
||||||
|
'product' => $product ? $this->normalizeProduct($product) : null,
|
||||||
|
'modelTypeId' => $modelType?->getId(),
|
||||||
|
'modelType' => $modelType ? $this->normalizeModelType($modelType) : null,
|
||||||
|
'pendingEntity' => null === $product,
|
||||||
|
'parentLinkId' => $link->getParentLink()?->getId(),
|
||||||
|
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
|
||||||
|
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
|
||||||
|
];
|
||||||
|
}, $links);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeComposant(Composant $composant): array
|
||||||
|
{
|
||||||
|
$type = $composant->getTypeComposant();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $composant->getId(),
|
||||||
|
'name' => $composant->getName(),
|
||||||
|
'reference' => $composant->getReference(),
|
||||||
|
'prix' => $composant->getPrix(),
|
||||||
|
'typeComposantId' => $type?->getId(),
|
||||||
|
'typeComposant' => $this->normalizeModelType($type),
|
||||||
|
'productId' => $composant->getProduct()?->getId(),
|
||||||
|
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
|
||||||
|
'structure' => $this->buildStructureFromSlots($composant),
|
||||||
|
'constructeurs' => $this->normalizeConstructeurLinks($composant->getConstructeurLinks()),
|
||||||
|
'documents' => [],
|
||||||
|
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getComponentCustomFields()) : [],
|
||||||
|
'customFieldValues' => $this->normalizeCustomFieldValues($composant->getCustomFieldValues()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildStructureFromSlots(Composant $composant): array
|
||||||
|
{
|
||||||
|
$pieces = [];
|
||||||
|
foreach ($composant->getPieceSlots() as $slot) {
|
||||||
|
$selectedPiece = $this->ensurePieceExists($slot->getSelectedPiece());
|
||||||
|
$pieceData = [
|
||||||
|
'slotId' => $slot->getId(),
|
||||||
|
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||||
|
'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
|
||||||
|
'quantity' => $slot->getQuantity(),
|
||||||
|
'selectedPieceId' => $selectedPiece?->getId(),
|
||||||
|
];
|
||||||
|
if ($selectedPiece) {
|
||||||
|
$pieceData['resolvedPiece'] = $this->normalizePiece($selectedPiece);
|
||||||
|
}
|
||||||
|
$pieces[] = $pieceData;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subcomponents = [];
|
||||||
|
foreach ($composant->getSubcomponentSlots() as $slot) {
|
||||||
|
$subcomponents[] = [
|
||||||
|
'alias' => $slot->getAlias(),
|
||||||
|
'familyCode' => $slot->getFamilyCode(),
|
||||||
|
'typeComposantId' => $slot->getTypeComposant()?->getId(),
|
||||||
|
'selectedComponentId' => $slot->getSelectedComposant()?->getId(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$products = [];
|
||||||
|
foreach ($composant->getProductSlots() as $slot) {
|
||||||
|
$products[] = [
|
||||||
|
'typeProductId' => $slot->getTypeProduct()?->getId(),
|
||||||
|
'familyCode' => $slot->getFamilyCode(),
|
||||||
|
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'pieces' => $pieces,
|
||||||
|
'subcomponents' => $subcomponents,
|
||||||
|
'products' => $products,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Piece if its underlying row still exists in DB, otherwise null.
|
||||||
|
* getId() on a Doctrine proxy does NOT trigger __load() (the id is the key used
|
||||||
|
* to build the proxy), so we force initialization via initializeObject() to
|
||||||
|
* surface a stale FK here instead of crashing on the first real getter.
|
||||||
|
*/
|
||||||
|
private function ensurePieceExists(?Piece $piece): ?Piece
|
||||||
|
{
|
||||||
|
if (null === $piece) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->entityManager->initializeObject($piece);
|
||||||
|
|
||||||
|
return $piece;
|
||||||
|
} catch (EntityNotFoundException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the CustomField if its underlying row still exists, otherwise null.
|
||||||
|
* getId() on a Doctrine proxy does NOT trigger __load() — the id is the key used
|
||||||
|
* to build the proxy. We force initialization explicitly so a stale FK to a
|
||||||
|
* deleted CustomField surfaces here instead of crashing on getName() later.
|
||||||
|
*/
|
||||||
|
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
|
||||||
|
{
|
||||||
|
if (null === $cf) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->entityManager->initializeObject($cf);
|
||||||
|
|
||||||
|
return $cf;
|
||||||
|
} catch (EntityNotFoundException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizePiece(Piece $piece): array
|
||||||
|
{
|
||||||
|
$type = $piece->getTypePiece();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $piece->getId(),
|
||||||
|
'name' => $piece->getName(),
|
||||||
|
'reference' => $piece->getReference(),
|
||||||
|
'prix' => $piece->getPrix(),
|
||||||
|
'typePieceId' => $type?->getId(),
|
||||||
|
'typePiece' => $this->normalizeModelType($type),
|
||||||
|
'productId' => $piece->getProduct()?->getId(),
|
||||||
|
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
|
||||||
|
'constructeurs' => $this->normalizeConstructeurLinks($piece->getConstructeurLinks()),
|
||||||
|
'documents' => [],
|
||||||
|
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getPieceCustomFields()) : [],
|
||||||
|
'customFieldValues' => $this->normalizeCustomFieldValues($piece->getCustomFieldValues()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeProduct(Product $product): array
|
||||||
|
{
|
||||||
|
$type = $product->getTypeProduct();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $product->getId(),
|
||||||
|
'name' => $product->getName(),
|
||||||
|
'reference' => $product->getReference(),
|
||||||
|
'supplierPrice' => $product->getSupplierPrice(),
|
||||||
|
'typeProductId' => $type?->getId(),
|
||||||
|
'typeProduct' => $this->normalizeModelType($type),
|
||||||
|
'constructeurs' => $this->normalizeConstructeurLinks($product->getConstructeurLinks()),
|
||||||
|
'documents' => [],
|
||||||
|
'customFields' => $type ? $this->normalizeCustomFieldDefinitions($type->getProductCustomFields()) : [],
|
||||||
|
'customFieldValues' => $this->normalizeCustomFieldValues($product->getCustomFieldValues()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeModelType(?ModelType $type): ?array
|
||||||
|
{
|
||||||
|
if (!$type instanceof ModelType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $type->getId(),
|
||||||
|
'name' => $type->getName(),
|
||||||
|
'code' => $type->getCode(),
|
||||||
|
'category' => $type->getCategory()->value,
|
||||||
|
'structure' => $type->getStructure(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeConstructeurLinks(Collection $constructeurLinks): array
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
foreach ($constructeurLinks as $link) {
|
||||||
|
$items[] = [
|
||||||
|
'id' => $link->getId(),
|
||||||
|
'constructeur' => [
|
||||||
|
'id' => $link->getConstructeur()->getId(),
|
||||||
|
'name' => $link->getConstructeur()->getName(),
|
||||||
|
'email' => $link->getConstructeur()->getEmail(),
|
||||||
|
'phone' => $this->constructeurPhone($link->getConstructeur()),
|
||||||
|
],
|
||||||
|
'supplierReference' => $link->getSupplierReference(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function constructeurPhone(Constructeur $constructeur): ?string
|
||||||
|
{
|
||||||
|
$first = $constructeur->getTelephones()->first();
|
||||||
|
|
||||||
|
return false !== $first ? $first->getNumero() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeCustomFieldDefinitions(Collection $customFields): array
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
foreach ($customFields as $cf) {
|
||||||
|
if (!$cf instanceof CustomField) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items[] = [
|
||||||
|
'id' => $cf->getId(),
|
||||||
|
'name' => $cf->getName(),
|
||||||
|
'type' => $cf->getType(),
|
||||||
|
'required' => $cf->isRequired(),
|
||||||
|
'options' => $cf->getOptions(),
|
||||||
|
'defaultValue' => $cf->getDefaultValue(),
|
||||||
|
'orderIndex' => $cf->getOrderIndex(),
|
||||||
|
'machineContextOnly' => $cf->isMachineContextOnly(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeCustomFieldValues(Collection $customFieldValues): array
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
foreach ($customFieldValues as $cfv) {
|
||||||
|
if (!$cfv instanceof CustomFieldValue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$cf = $this->ensureCustomFieldExists($cfv->getCustomField());
|
||||||
|
if (null === $cf) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items[] = [
|
||||||
|
'id' => $cfv->getId(),
|
||||||
|
'value' => $cfv->getValue(),
|
||||||
|
'customField' => [
|
||||||
|
'id' => $cf->getId(),
|
||||||
|
'name' => $cf->getName(),
|
||||||
|
'type' => $cf->getType(),
|
||||||
|
'required' => $cf->isRequired(),
|
||||||
|
'options' => $cf->getOptions(),
|
||||||
|
'defaultValue' => $cf->getDefaultValue(),
|
||||||
|
'orderIndex' => $cf->getOrderIndex(),
|
||||||
|
'machineContextOnly' => $cf->isMachineContextOnly(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeContextCustomFieldDefinitions(Collection $customFields): array
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
foreach ($customFields as $cf) {
|
||||||
|
if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$items[] = [
|
||||||
|
'id' => $cf->getId(),
|
||||||
|
'name' => $cf->getName(),
|
||||||
|
'type' => $cf->getType(),
|
||||||
|
'required' => $cf->isRequired(),
|
||||||
|
'options' => $cf->getOptions(),
|
||||||
|
'defaultValue' => $cf->getDefaultValue(),
|
||||||
|
'orderIndex' => $cf->getOrderIndex(),
|
||||||
|
'machineContextOnly' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
|
||||||
|
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeOverrides(object $link): ?array
|
||||||
|
{
|
||||||
|
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;
|
||||||
|
$reference = method_exists($link, 'getReferenceOverride') ? $link->getReferenceOverride() : null;
|
||||||
|
$prix = method_exists($link, 'getPrixOverride') ? $link->getPrixOverride() : null;
|
||||||
|
|
||||||
|
if (null === $name && null === $reference && null === $prix) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'reference' => $reference,
|
||||||
|
'prix' => $prix,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function indexNormalizedLinks(array $links): array
|
||||||
|
{
|
||||||
|
$indexed = [];
|
||||||
|
foreach ($links as $link) {
|
||||||
|
if (is_array($link) && isset($link['id'])) {
|
||||||
|
$indexed[$link['id']] = $link;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $indexed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\MachineStructure;
|
||||||
|
|
||||||
|
use App\Entity\Composant;
|
||||||
|
use App\Entity\Machine;
|
||||||
|
use App\Entity\MachineComponentLink;
|
||||||
|
use App\Entity\MachinePieceLink;
|
||||||
|
use App\Entity\MachineProductLink;
|
||||||
|
use App\Entity\Piece;
|
||||||
|
use App\Entity\Product;
|
||||||
|
use App\Repository\ComposantRepository;
|
||||||
|
use App\Repository\MachineComponentLinkRepository;
|
||||||
|
use App\Repository\MachinePieceLinkRepository;
|
||||||
|
use App\Repository\MachineProductLinkRepository;
|
||||||
|
use App\Repository\PieceRepository;
|
||||||
|
use App\Repository\ProductRepository;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a structure payload (component/piece/product links) to a machine:
|
||||||
|
* upserts links, wires the hierarchy, removes the links that disappeared, then
|
||||||
|
* flushes. Extracted from MachineStructureController.
|
||||||
|
*/
|
||||||
|
class MachineStructureUpdater
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
|
||||||
|
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
|
||||||
|
private readonly MachineProductLinkRepository $machineProductLinkRepository,
|
||||||
|
private readonly ComposantRepository $composantRepository,
|
||||||
|
private readonly PieceRepository $pieceRepository,
|
||||||
|
private readonly ProductRepository $productRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{componentLinks: list<MachineComponentLink>, pieceLinks: list<MachinePieceLink>, productLinks: list<MachineProductLink>}
|
||||||
|
*
|
||||||
|
* @throws MachineStructureException on invalid payload / missing entity
|
||||||
|
*/
|
||||||
|
public function apply(Machine $machine, array $payload): array
|
||||||
|
{
|
||||||
|
$componentLinksPayload = $this->normalizePayloadList($payload['componentLinks'] ?? []);
|
||||||
|
$pieceLinksPayload = $this->normalizePayloadList($payload['pieceLinks'] ?? []);
|
||||||
|
$productLinksPayload = $this->normalizePayloadList($payload['productLinks'] ?? []);
|
||||||
|
|
||||||
|
$componentLinks = $this->applyComponentLinks($machine, $componentLinksPayload);
|
||||||
|
$pieceLinks = $this->applyPieceLinks($machine, $pieceLinksPayload, $componentLinks);
|
||||||
|
$productLinks = $this->applyProductLinks($machine, $productLinksPayload, $componentLinks, $pieceLinks);
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'componentLinks' => $componentLinks,
|
||||||
|
'pieceLinks' => $pieceLinks,
|
||||||
|
'productLinks' => $productLinks,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizePayloadList(mixed $value): array
|
||||||
|
{
|
||||||
|
if (!is_array($value)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter($value, static fn ($item) => is_array($item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyComponentLinks(Machine $machine, array $payload): array
|
||||||
|
{
|
||||||
|
$existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']));
|
||||||
|
$keepIds = [];
|
||||||
|
$pendingParents = [];
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
foreach ($payload as $entry) {
|
||||||
|
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
|
||||||
|
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineComponentLink();
|
||||||
|
if (!$linkId) {
|
||||||
|
$linkId = $this->generateCuid();
|
||||||
|
}
|
||||||
|
if (!$link->getId()) {
|
||||||
|
$link->setId($linkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$composantId = $this->resolveIdentifier($entry, ['composantId', 'componentId', 'idComposant']);
|
||||||
|
if (!$composantId) {
|
||||||
|
throw new MachineStructureException('Composant requis.', 400);
|
||||||
|
}
|
||||||
|
$composant = $this->composantRepository->find($composantId);
|
||||||
|
if (!$composant instanceof Composant) {
|
||||||
|
throw new MachineStructureException('Composant introuvable.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$link->setMachine($machine);
|
||||||
|
$link->setComposant($composant);
|
||||||
|
|
||||||
|
$this->applyOverrides($link, $entry['overrides'] ?? null);
|
||||||
|
|
||||||
|
$pendingParents[$linkId] = $this->resolveIdentifier($entry, [
|
||||||
|
'parentComponentLinkId',
|
||||||
|
'parentLinkId',
|
||||||
|
'parentMachineComponentLinkId',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->entityManager->persist($link);
|
||||||
|
$links[$linkId] = $link;
|
||||||
|
$keepIds[] = $linkId;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($pendingParents as $linkId => $parentId) {
|
||||||
|
if (!$parentId || !isset($links[$linkId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$parent = $links[$parentId] ?? $existing[$parentId] ?? null;
|
||||||
|
if ($parent instanceof MachineComponentLink) {
|
||||||
|
$links[$linkId]->setParentLink($parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->removeMissingLinks($existing, $keepIds);
|
||||||
|
|
||||||
|
return array_values($links);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyPieceLinks(Machine $machine, array $payload, array $componentLinks): array
|
||||||
|
{
|
||||||
|
$existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']));
|
||||||
|
$componentIndex = $this->indexLinksById($componentLinks);
|
||||||
|
$keepIds = [];
|
||||||
|
$pendingParents = [];
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
foreach ($payload as $entry) {
|
||||||
|
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
|
||||||
|
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachinePieceLink();
|
||||||
|
if (!$linkId) {
|
||||||
|
$linkId = $this->generateCuid();
|
||||||
|
}
|
||||||
|
if (!$link->getId()) {
|
||||||
|
$link->setId($linkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pieceId = $this->resolveIdentifier($entry, ['pieceId']);
|
||||||
|
if (!$pieceId) {
|
||||||
|
throw new MachineStructureException('Pièce requise.', 400);
|
||||||
|
}
|
||||||
|
$piece = $this->pieceRepository->find($pieceId);
|
||||||
|
if (!$piece instanceof Piece) {
|
||||||
|
throw new MachineStructureException('Pièce introuvable.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$link->setMachine($machine);
|
||||||
|
$link->setPiece($piece);
|
||||||
|
|
||||||
|
$this->applyOverrides($link, $entry['overrides'] ?? null);
|
||||||
|
|
||||||
|
if (!isset($entry['parentComponentLinkId']) && !isset($entry['parentLinkId'])) {
|
||||||
|
$quantity = isset($entry['quantity']) ? (int) $entry['quantity'] : $link->getQuantity();
|
||||||
|
$link->setQuantity(max(1, $quantity));
|
||||||
|
}
|
||||||
|
|
||||||
|
$pendingParents[$linkId] = $this->resolveIdentifier($entry, [
|
||||||
|
'parentComponentLinkId',
|
||||||
|
'parentLinkId',
|
||||||
|
'parentMachineComponentLinkId',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->entityManager->persist($link);
|
||||||
|
$links[$linkId] = $link;
|
||||||
|
$keepIds[] = $linkId;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($pendingParents as $linkId => $parentId) {
|
||||||
|
if (!$parentId || !isset($links[$linkId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$parent = $componentIndex[$parentId] ?? null;
|
||||||
|
if ($parent instanceof MachineComponentLink) {
|
||||||
|
$links[$linkId]->setParentLink($parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->removeMissingLinks($existing, $keepIds);
|
||||||
|
|
||||||
|
return array_values($links);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyProductLinks(
|
||||||
|
Machine $machine,
|
||||||
|
array $payload,
|
||||||
|
array $componentLinks,
|
||||||
|
array $pieceLinks,
|
||||||
|
): array {
|
||||||
|
$existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine], ['createdAt' => 'ASC']));
|
||||||
|
$componentIndex = $this->indexLinksById($componentLinks);
|
||||||
|
$pieceIndex = $this->indexLinksById($pieceLinks);
|
||||||
|
$keepIds = [];
|
||||||
|
$pendingParents = [];
|
||||||
|
$links = [];
|
||||||
|
|
||||||
|
foreach ($payload as $entry) {
|
||||||
|
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
|
||||||
|
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineProductLink();
|
||||||
|
if (!$linkId) {
|
||||||
|
$linkId = $this->generateCuid();
|
||||||
|
}
|
||||||
|
if (!$link->getId()) {
|
||||||
|
$link->setId($linkId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$productId = $this->resolveIdentifier($entry, ['productId']);
|
||||||
|
if (!$productId) {
|
||||||
|
throw new MachineStructureException('Produit requis.', 400);
|
||||||
|
}
|
||||||
|
$product = $this->productRepository->find($productId);
|
||||||
|
if (!$product instanceof Product) {
|
||||||
|
throw new MachineStructureException('Produit introuvable.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$link->setMachine($machine);
|
||||||
|
$link->setProduct($product);
|
||||||
|
|
||||||
|
$pendingParents[$linkId] = [
|
||||||
|
'parentComponentLinkId' => $this->resolveIdentifier($entry, ['parentComponentLinkId']),
|
||||||
|
'parentPieceLinkId' => $this->resolveIdentifier($entry, ['parentPieceLinkId']),
|
||||||
|
'parentLinkId' => $this->resolveIdentifier($entry, ['parentLinkId']),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->entityManager->persist($link);
|
||||||
|
$links[$linkId] = $link;
|
||||||
|
$keepIds[] = $linkId;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($pendingParents as $linkId => $parentIds) {
|
||||||
|
if (!isset($links[$linkId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!empty($parentIds['parentComponentLinkId']) && isset($componentIndex[$parentIds['parentComponentLinkId']])) {
|
||||||
|
$links[$linkId]->setParentComponentLink($componentIndex[$parentIds['parentComponentLinkId']]);
|
||||||
|
}
|
||||||
|
if (!empty($parentIds['parentPieceLinkId']) && isset($pieceIndex[$parentIds['parentPieceLinkId']])) {
|
||||||
|
$links[$linkId]->setParentPieceLink($pieceIndex[$parentIds['parentPieceLinkId']]);
|
||||||
|
}
|
||||||
|
if (!empty($parentIds['parentLinkId']) && isset($links[$parentIds['parentLinkId']])) {
|
||||||
|
$links[$linkId]->setParentLink($links[$parentIds['parentLinkId']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->removeMissingLinks($existing, $keepIds);
|
||||||
|
|
||||||
|
return array_values($links);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyOverrides(object $link, mixed $overrides): void
|
||||||
|
{
|
||||||
|
if (!is_array($overrides)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('name', $overrides) && method_exists($link, 'setNameOverride')) {
|
||||||
|
$link->setNameOverride($this->stringOrNull($overrides['name']));
|
||||||
|
}
|
||||||
|
if (array_key_exists('reference', $overrides) && method_exists($link, 'setReferenceOverride')) {
|
||||||
|
$link->setReferenceOverride($this->stringOrNull($overrides['reference']));
|
||||||
|
}
|
||||||
|
if (array_key_exists('prix', $overrides) && method_exists($link, 'setPrixOverride')) {
|
||||||
|
$link->setPrixOverride($this->stringOrNull($overrides['prix']));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stringOrNull(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$string = trim((string) $value);
|
||||||
|
|
||||||
|
return '' === $string ? null : $string;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveIdentifier(array $entry, array $keys): ?string
|
||||||
|
{
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
if (!array_key_exists($key, $entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$value = $entry[$key];
|
||||||
|
if (null === $value || '' === $value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array-key, object> $links
|
||||||
|
*
|
||||||
|
* @return array<string, object>
|
||||||
|
*/
|
||||||
|
private function indexLinksById(array $links): array
|
||||||
|
{
|
||||||
|
$indexed = [];
|
||||||
|
foreach ($links as $link) {
|
||||||
|
if (method_exists($link, 'getId') && $link->getId()) {
|
||||||
|
$indexed[$link->getId()] = $link;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $indexed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function removeMissingLinks(array $existing, array $keepIds): void
|
||||||
|
{
|
||||||
|
$keep = array_flip($keepIds);
|
||||||
|
foreach ($existing as $link) {
|
||||||
|
if (!method_exists($link, 'getId')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$id = $link->getId();
|
||||||
|
if ($id && !isset($keep[$id])) {
|
||||||
|
$this->entityManager->remove($link);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateCuid(): string
|
||||||
|
{
|
||||||
|
return 'cl'.bin2hex(random_bytes(12));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -308,4 +308,88 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
$this->assertCount(1, $sourceLink['contextCustomFieldValues']);
|
$this->assertCount(1, $sourceLink['contextCustomFieldValues']);
|
||||||
$this->assertSame('1500', $sourceLink['contextCustomFieldValues'][0]['value']);
|
$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