feat(categories) : add bidirectional piece/component category conversion

Backend service and controller for converting piece categories to component
categories (and vice-versa). Uses raw SQL in a transaction to preserve IDs
and transfer all related data (documents, custom fields, constructeurs).
Includes php-cs-fixer formatting pass on existing controllers/entities.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-02-12 14:27:07 +01:00
parent 6300a3588a
commit cd2a3fac55
23 changed files with 821 additions and 340 deletions

View File

@@ -7,6 +7,7 @@ namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\ComposantRepository;
use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -17,8 +18,7 @@ final class ComposantHistoryController
private readonly ComposantRepository $components,
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {
}
) {}
#[Route('/api/composants/{id}/history', name: 'api_composant_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse
@@ -39,11 +39,11 @@ final class ComposantHistoryController
))));
$actorMap = [];
if ($actorIds !== []) {
if ([] !== $actorIds) {
$profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') {
if ('' === $label) {
$label = $profile->getEmail() ?? $profile->getId();
}
$actorMap[$profile->getId()] = $label;
@@ -55,16 +55,16 @@ final class ComposantHistoryController
$actorId = $log->getActorProfileId();
return [
'id' => $log->getId(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM),
'actor' => $actorId
'id' => $log->getId(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId
? [
'id' => $actorId,
'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId,
]
: null,
'diff' => $log->getDiff(),
'diff' => $log->getDiff(),
'snapshot' => $log->getSnapshot(),
];
},
@@ -77,4 +77,3 @@ final class ComposantHistoryController
]);
}
}

View File

@@ -29,8 +29,7 @@ class CustomFieldValueController extends AbstractController
private readonly ComposantRepository $composantRepository,
private readonly PieceRepository $pieceRepository,
private readonly ProductRepository $productRepository,
) {
}
) {}
#[Route('', name: 'custom_field_values_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
@@ -80,7 +79,7 @@ class CustomFieldValueController extends AbstractController
}
$existing = $this->customFieldValueRepository->findOneBy([
'customField' => $customField,
'customField' => $customField,
$target['type'] => $target['entity'],
]);
@@ -107,7 +106,7 @@ class CustomFieldValueController extends AbstractController
{
$target = $this->resolveTarget([
'entityType' => $entityType,
'entityId' => $entityId,
'entityId' => $entityId,
]);
if ($target instanceof JsonResponse) {
@@ -173,7 +172,7 @@ class CustomFieldValueController extends AbstractController
private function resolveCustomField(array $payload): CustomField|JsonResponse
{
$customFieldId = isset($payload['customFieldId']) ? trim((string) $payload['customFieldId']) : '';
if ($customFieldId !== '') {
if ('' !== $customFieldId) {
$customField = $this->customFieldRepository->find($customFieldId);
if ($customField instanceof CustomField) {
return $customField;
@@ -183,7 +182,7 @@ class CustomFieldValueController extends AbstractController
}
$customFieldName = isset($payload['customFieldName']) ? trim((string) $payload['customFieldName']) : '';
if ($customFieldName === '') {
if ('' === $customFieldName) {
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
}
@@ -205,30 +204,31 @@ class CustomFieldValueController extends AbstractController
private function resolveTarget(array $payload): array|JsonResponse
{
$entityType = isset($payload['entityType']) ? strtolower((string) $payload['entityType']) : '';
$entityId = isset($payload['entityId']) ? trim((string) $payload['entityId']) : '';
$entityId = isset($payload['entityId']) ? trim((string) $payload['entityId']) : '';
if ($entityType === '' || $entityId === '') {
if ('' === $entityType || '' === $entityId) {
foreach (['machine', 'composant', 'piece', 'product'] as $candidate) {
$key = $candidate . 'Id';
$key = $candidate.'Id';
if (!isset($payload[$key])) {
continue;
}
$entityType = $candidate;
$entityId = trim((string) $payload[$key]);
$entityId = trim((string) $payload[$key]);
break;
}
}
if ($entityType === '' || $entityId === '') {
if ('' === $entityType || '' === $entityId) {
return $this->json(['success' => false, 'error' => 'Entity target is missing.'], 400);
}
return match ($entityType) {
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository),
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
};
}
@@ -247,15 +247,22 @@ class CustomFieldValueController extends AbstractController
switch ($type) {
case 'machine':
$value->setMachine($entity);
break;
case 'composant':
$value->setComposant($entity);
break;
case 'piece':
$value->setPiece($entity);
break;
case 'product':
$value->setProduct($entity);
break;
}
}
@@ -265,23 +272,23 @@ class CustomFieldValueController extends AbstractController
$customField = $value->getCustomField();
return [
'id' => $value->getId(),
'value' => $value->getValue(),
'id' => $value->getId(),
'value' => $value->getValue(),
'customFieldId' => $customField->getId(),
'customField' => [
'id' => $customField->getId(),
'name' => $customField->getName(),
'type' => $customField->getType(),
'required' => $customField->isRequired(),
'options' => $customField->getOptions(),
'customField' => [
'id' => $customField->getId(),
'name' => $customField->getName(),
'type' => $customField->getType(),
'required' => $customField->isRequired(),
'options' => $customField->getOptions(),
'orderIndex' => $customField->getOrderIndex(),
],
'machineId' => $value->getMachine()?->getId(),
'machineId' => $value->getMachine()?->getId(),
'composantId' => $value->getComposant()?->getId(),
'pieceId' => $value->getPiece()?->getId(),
'productId' => $value->getProduct()?->getId(),
'createdAt' => $value->getCreatedAt()->format(DATE_ATOM),
'updatedAt' => $value->getUpdatedAt()->format(DATE_ATOM),
'pieceId' => $value->getPiece()?->getId(),
'productId' => $value->getProduct()?->getId(),
'createdAt' => $value->getCreatedAt()->format(DATE_ATOM),
'updatedAt' => $value->getUpdatedAt()->format(DATE_ATOM),
];
}
}

View File

@@ -25,8 +25,7 @@ class DocumentQueryController extends AbstractController
private readonly ComposantRepository $composantRepository,
private readonly PieceRepository $pieceRepository,
private readonly ProductRepository $productRepository,
) {
}
) {}
#[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])]
public function listBySite(string $id): JsonResponse
@@ -100,19 +99,19 @@ class DocumentQueryController extends AbstractController
{
return array_map(static function (Document $document): array {
return [
'id' => $document->getId(),
'name' => $document->getName(),
'filename' => $document->getFilename(),
'path' => $document->getPath(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'siteId' => $document->getSite()?->getId(),
'machineId' => $document->getMachine()?->getId(),
'id' => $document->getId(),
'name' => $document->getName(),
'filename' => $document->getFilename(),
'path' => $document->getPath(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'siteId' => $document->getSite()?->getId(),
'machineId' => $document->getMachine()?->getId(),
'composantId' => $document->getComposant()?->getId(),
'pieceId' => $document->getPiece()?->getId(),
'productId' => $document->getProduct()?->getId(),
'createdAt' => $document->getCreatedAt()->format(DATE_ATOM),
'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM),
'pieceId' => $document->getPiece()?->getId(),
'productId' => $document->getProduct()?->getId(),
'createdAt' => $document->getCreatedAt()->format(DATE_ATOM),
'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM),
];
}, $documents);
}

View File

@@ -21,8 +21,7 @@ class MachineCustomFieldsController extends AbstractController
private readonly EntityManagerInterface $entityManager,
private readonly MachineRepository $machineRepository,
private readonly CustomFieldValueRepository $customFieldValueRepository,
) {
}
) {}
#[Route('/{id}/add-custom-fields', name: 'machine_add_custom_fields', methods: ['POST'])]
public function addMissingCustomFields(string $id): JsonResponse
@@ -42,7 +41,7 @@ class MachineCustomFieldsController extends AbstractController
continue;
}
$existing = $this->customFieldValueRepository->findOneBy([
'machine' => $machine,
'machine' => $machine,
'customField' => $customField,
]);
if ($existing instanceof CustomFieldValue) {
@@ -61,12 +60,12 @@ class MachineCustomFieldsController extends AbstractController
$values = $this->customFieldValueRepository->findBy(['machine' => $machine]);
return $this->json([
'success' => true,
'machineId' => $machine->getId(),
'success' => true,
'machineId' => $machine->getId(),
'customFieldValues' => array_map(
static fn (CustomFieldValue $value) => [
'id' => $value->getId(),
'value' => $value->getValue(),
'id' => $value->getId(),
'value' => $value->getValue(),
'customFieldId' => $value->getCustomField()->getId(),
],
$values

View File

@@ -47,8 +47,7 @@ class MachineSkeletonController extends AbstractController
private readonly TypeMachineComponentRequirementRepository $componentRequirementRepository,
private readonly TypeMachinePieceRequirementRepository $pieceRequirementRepository,
private readonly TypeMachineProductRequirementRepository $productRequirementRepository,
) {
}
) {}
#[Route('/{id}/skeleton', name: 'machine_skeleton_get', methods: ['GET'])]
public function getSkeleton(string $id): JsonResponse
@@ -59,8 +58,8 @@ class MachineSkeletonController extends AbstractController
}
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine]);
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
return $this->json($this->normalizeMachineSkeletonResponse(
$machine,
@@ -84,8 +83,8 @@ class MachineSkeletonController extends AbstractController
}
$componentLinksPayload = $this->normalizePayloadList($payload['componentLinks'] ?? []);
$pieceLinksPayload = $this->normalizePayloadList($payload['pieceLinks'] ?? []);
$productLinksPayload = $this->normalizePayloadList($payload['productLinks'] ?? []);
$pieceLinksPayload = $this->normalizePayloadList($payload['pieceLinks'] ?? []);
$productLinksPayload = $this->normalizePayloadList($payload['productLinks'] ?? []);
$componentLinks = $this->applyComponentLinks($machine, $componentLinksPayload);
if ($componentLinks instanceof JsonResponse) {
@@ -117,19 +116,20 @@ class MachineSkeletonController extends AbstractController
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|JsonResponse
{
$existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine]));
$keepIds = [];
$existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine]));
$keepIds = [];
$pendingParents = [];
$links = [];
$links = [];
foreach ($payload as $entry) {
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineComponentLink();
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineComponentLink();
if (!$linkId) {
$linkId = $this->generateCuid();
}
@@ -167,7 +167,7 @@ class MachineSkeletonController extends AbstractController
$this->entityManager->persist($link);
$links[$linkId] = $link;
$keepIds[] = $linkId;
$keepIds[] = $linkId;
}
foreach ($pendingParents as $linkId => $parentId) {
@@ -190,15 +190,15 @@ class MachineSkeletonController extends AbstractController
private function applyPieceLinks(Machine $machine, array $payload, array $componentLinks): array|JsonResponse
{
$existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine]));
$existing = $this->indexLinksById($this->machinePieceLinkRepository->findBy(['machine' => $machine]));
$componentIndex = $this->indexLinksById($componentLinks);
$keepIds = [];
$keepIds = [];
$pendingParents = [];
$links = [];
$links = [];
foreach ($payload as $entry) {
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachinePieceLink();
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachinePieceLink();
if (!$linkId) {
$linkId = $this->generateCuid();
}
@@ -236,7 +236,7 @@ class MachineSkeletonController extends AbstractController
$this->entityManager->persist($link);
$links[$linkId] = $link;
$keepIds[] = $linkId;
$keepIds[] = $linkId;
}
foreach ($pendingParents as $linkId => $parentId) {
@@ -263,16 +263,16 @@ class MachineSkeletonController extends AbstractController
array $componentLinks,
array $pieceLinks,
): array|JsonResponse {
$existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine]));
$existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine]));
$componentIndex = $this->indexLinksById($componentLinks);
$pieceIndex = $this->indexLinksById($pieceLinks);
$keepIds = [];
$pieceIndex = $this->indexLinksById($pieceLinks);
$keepIds = [];
$pendingParents = [];
$links = [];
$links = [];
foreach ($payload as $entry) {
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']);
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineProductLink();
$link = $linkId && isset($existing[$linkId]) ? $existing[$linkId] : new MachineProductLink();
if (!$linkId) {
$linkId = $this->generateCuid();
}
@@ -302,13 +302,13 @@ class MachineSkeletonController extends AbstractController
$pendingParents[$linkId] = [
'parentComponentLinkId' => $this->resolveIdentifier($entry, ['parentComponentLinkId']),
'parentPieceLinkId' => $this->resolveIdentifier($entry, ['parentPieceLinkId']),
'parentLinkId' => $this->resolveIdentifier($entry, ['parentLinkId']),
'parentPieceLinkId' => $this->resolveIdentifier($entry, ['parentPieceLinkId']),
'parentLinkId' => $this->resolveIdentifier($entry, ['parentLinkId']),
];
$this->entityManager->persist($link);
$links[$linkId] = $link;
$keepIds[] = $linkId;
$keepIds[] = $linkId;
}
foreach ($pendingParents as $linkId => $parentIds) {
@@ -338,8 +338,8 @@ class MachineSkeletonController extends AbstractController
array $productLinks,
): array {
$normalizedComponentLinks = $this->normalizeComponentLinks($componentLinks);
$componentIndex = $this->indexNormalizedLinks($normalizedComponentLinks);
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
$componentIndex = $this->indexNormalizedLinks($normalizedComponentLinks);
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
// Build component hierarchy
foreach ($normalizedComponentLinks as &$link) {
@@ -354,10 +354,10 @@ class MachineSkeletonController extends AbstractController
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
return [
'machine' => $this->normalizeMachine($machine),
'machine' => $this->normalizeMachine($machine),
'componentLinks' => array_values($componentIndex),
'pieceLinks' => $normalizedPieceLinks,
'productLinks' => $this->normalizeProductLinks($productLinks),
'pieceLinks' => $normalizedPieceLinks,
'productLinks' => $this->normalizeProductLinks($productLinks),
];
}
@@ -400,26 +400,26 @@ class MachineSkeletonController extends AbstractController
private function normalizeMachine(Machine $machine): array
{
$site = $machine->getSite();
$site = $machine->getSite();
$typeMachine = $machine->getTypeMachine();
return [
'id' => $machine->getId(),
'name' => $machine->getName(),
'id' => $machine->getId(),
'name' => $machine->getName(),
'reference' => $machine->getReference(),
'prix' => $machine->getPrix(),
'siteId' => $site->getId(),
'site' => [
'id' => $site->getId(),
'prix' => $machine->getPrix(),
'siteId' => $site->getId(),
'site' => [
'id' => $site->getId(),
'name' => $site->getName(),
],
'typeMachineId' => $typeMachine?->getId(),
'typeMachine' => $typeMachine ? [
'id' => $typeMachine->getId(),
'name' => $typeMachine->getName(),
'category' => $typeMachine->getCategory(),
'description' => $typeMachine->getDescription(),
'customFields' => $this->normalizeCustomFields($typeMachine->getCustomFields()),
'typeMachine' => $typeMachine ? [
'id' => $typeMachine->getId(),
'name' => $typeMachine->getName(),
'category' => $typeMachine->getCategory(),
'description' => $typeMachine->getDescription(),
'customFields' => $this->normalizeCustomFields($typeMachine->getCustomFields()),
'componentRequirements' => $typeMachine->getComponentRequirements()
->map(fn (TypeMachineComponentRequirement $req) => $this->normalizeComponentRequirement($req))
->toArray(),
@@ -430,8 +430,8 @@ class MachineSkeletonController extends AbstractController
->map(fn (TypeMachineProductRequirement $req) => $this->normalizeProductRequirement($req))
->toArray(),
] : null,
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
'documents' => null,
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
'documents' => null,
'customFieldValues' => null,
];
}
@@ -444,13 +444,13 @@ class MachineSkeletonController extends AbstractController
continue;
}
$items[] = [
'id' => $customField->getId(),
'name' => $customField->getName(),
'type' => $customField->getType(),
'required' => $customField->isRequired(),
'options' => $customField->getOptions(),
'id' => $customField->getId(),
'name' => $customField->getName(),
'type' => $customField->getType(),
'required' => $customField->isRequired(),
'options' => $customField->getOptions(),
'defaultValue' => $customField->getDefaultValue(),
'orderIndex' => $customField->getOrderIndex(),
'orderIndex' => $customField->getOrderIndex(),
];
}
@@ -460,26 +460,26 @@ class MachineSkeletonController extends AbstractController
private function normalizeComponentLinks(array $links): array
{
return array_map(function (MachineComponentLink $link): array {
$composant = $link->getComposant();
$requirement = $link->getTypeMachineComponentRequirement();
$parentLink = $link->getParentLink();
$composant = $link->getComposant();
$requirement = $link->getTypeMachineComponentRequirement();
$parentLink = $link->getParentLink();
$parentRequirementId = $parentLink?->getTypeMachineComponentRequirement()?->getId();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'composantId' => $composant->getId(),
'composant' => $this->normalizeComposant($composant),
'typeMachineComponentRequirementId' => $requirement?->getId(),
'typeMachineComponentRequirement' => $requirement ? $this->normalizeComponentRequirement($requirement) : null,
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'composantId' => $composant->getId(),
'composant' => $this->normalizeComposant($composant),
'typeMachineComponentRequirementId' => $requirement?->getId(),
'typeMachineComponentRequirement' => $requirement ? $this->normalizeComponentRequirement($requirement) : null,
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'parentMachineComponentRequirementId' => $parentRequirementId,
'overrides' => $this->normalizeOverrides($link),
'childLinks' => [],
'pieceLinks' => [],
'overrides' => $this->normalizeOverrides($link),
'childLinks' => [],
'pieceLinks' => [],
];
}, $links);
}
@@ -487,24 +487,24 @@ class MachineSkeletonController extends AbstractController
private function normalizePieceLinks(array $links): array
{
return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece();
$requirement = $link->getTypeMachinePieceRequirement();
$parentLink = $link->getParentLink();
$piece = $link->getPiece();
$requirement = $link->getTypeMachinePieceRequirement();
$parentLink = $link->getParentLink();
$parentRequirementId = $parentLink?->getTypeMachineComponentRequirement()?->getId();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'pieceId' => $piece->getId(),
'piece' => $this->normalizePiece($piece),
'typeMachinePieceRequirementId' => $requirement?->getId(),
'typeMachinePieceRequirement' => $requirement ? $this->normalizePieceRequirement($requirement) : null,
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'pieceId' => $piece->getId(),
'piece' => $this->normalizePiece($piece),
'typeMachinePieceRequirementId' => $requirement?->getId(),
'typeMachinePieceRequirement' => $requirement ? $this->normalizePieceRequirement($requirement) : null,
'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(),
'parentMachineComponentRequirementId' => $parentRequirementId,
'overrides' => $this->normalizeOverrides($link),
'overrides' => $this->normalizeOverrides($link),
];
}, $links);
}
@@ -512,20 +512,20 @@ class MachineSkeletonController extends AbstractController
private function normalizeProductLinks(array $links): array
{
return array_map(function (MachineProductLink $link): array {
$product = $link->getProduct();
$product = $link->getProduct();
$requirement = $link->getTypeMachineProductRequirement();
return [
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'productId' => $product->getId(),
'product' => $this->normalizeProduct($product),
'id' => $link->getId(),
'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(),
'productId' => $product->getId(),
'product' => $this->normalizeProduct($product),
'typeMachineProductRequirementId' => $requirement?->getId(),
'typeMachineProductRequirement' => $requirement ? $this->normalizeProductRequirement($requirement) : null,
'parentLinkId' => $link->getParentLink()?->getId(),
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
'typeMachineProductRequirement' => $requirement ? $this->normalizeProductRequirement($requirement) : null,
'parentLinkId' => $link->getParentLink()?->getId(),
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
];
}, $links);
}
@@ -533,49 +533,49 @@ class MachineSkeletonController extends AbstractController
private function normalizeComposant(Composant $composant): array
{
return [
'id' => $composant->getId(),
'name' => $composant->getName(),
'reference' => $composant->getReference(),
'prix' => $composant->getPrix(),
'id' => $composant->getId(),
'name' => $composant->getName(),
'reference' => $composant->getReference(),
'prix' => $composant->getPrix(),
'typeComposantId' => $composant->getTypeComposant()?->getId(),
'typeComposant' => $this->normalizeModelType($composant->getTypeComposant()),
'productId' => $composant->getProduct()?->getId(),
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
'documents' => [],
'customFields' => [],
'typeComposant' => $this->normalizeModelType($composant->getTypeComposant()),
'productId' => $composant->getProduct()?->getId(),
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
'documents' => [],
'customFields' => [],
];
}
private function normalizePiece(Piece $piece): array
{
return [
'id' => $piece->getId(),
'name' => $piece->getName(),
'reference' => $piece->getReference(),
'prix' => $piece->getPrix(),
'typePieceId' => $piece->getTypePiece()?->getId(),
'typePiece' => $this->normalizeModelType($piece->getTypePiece()),
'productId' => $piece->getProduct()?->getId(),
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
'id' => $piece->getId(),
'name' => $piece->getName(),
'reference' => $piece->getReference(),
'prix' => $piece->getPrix(),
'typePieceId' => $piece->getTypePiece()?->getId(),
'typePiece' => $this->normalizeModelType($piece->getTypePiece()),
'productId' => $piece->getProduct()?->getId(),
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()),
'documents' => [],
'customFields' => [],
'documents' => [],
'customFields' => [],
];
}
private function normalizeProduct(Product $product): array
{
return [
'id' => $product->getId(),
'name' => $product->getName(),
'reference' => $product->getReference(),
'id' => $product->getId(),
'name' => $product->getName(),
'reference' => $product->getReference(),
'supplierPrice' => $product->getSupplierPrice(),
'typeProductId' => $product->getTypeProduct()?->getId(),
'typeProduct' => $this->normalizeModelType($product->getTypeProduct()),
'typeProduct' => $this->normalizeModelType($product->getTypeProduct()),
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
'documents' => [],
'customFields' => [],
'documents' => [],
'customFields' => [],
];
}
@@ -586,9 +586,9 @@ class MachineSkeletonController extends AbstractController
}
return [
'id' => $type->getId(),
'name' => $type->getName(),
'code' => $type->getCode(),
'id' => $type->getId(),
'name' => $type->getName(),
'code' => $type->getCode(),
'category' => $type->getCategory()->value,
];
}
@@ -596,39 +596,39 @@ class MachineSkeletonController extends AbstractController
private function normalizeComponentRequirement(TypeMachineComponentRequirement $requirement): array
{
return [
'id' => $requirement->getId(),
'label' => $requirement->getLabel(),
'minCount' => $requirement->getMinCount(),
'maxCount' => $requirement->getMaxCount(),
'required' => $requirement->isRequired(),
'id' => $requirement->getId(),
'label' => $requirement->getLabel(),
'minCount' => $requirement->getMinCount(),
'maxCount' => $requirement->getMaxCount(),
'required' => $requirement->isRequired(),
'typeComposantId' => $requirement->getTypeComposant()->getId(),
'typeComposant' => $this->normalizeModelType($requirement->getTypeComposant()),
'typeComposant' => $this->normalizeModelType($requirement->getTypeComposant()),
];
}
private function normalizePieceRequirement(TypeMachinePieceRequirement $requirement): array
{
return [
'id' => $requirement->getId(),
'label' => $requirement->getLabel(),
'minCount' => $requirement->getMinCount(),
'maxCount' => $requirement->getMaxCount(),
'required' => $requirement->isRequired(),
'id' => $requirement->getId(),
'label' => $requirement->getLabel(),
'minCount' => $requirement->getMinCount(),
'maxCount' => $requirement->getMaxCount(),
'required' => $requirement->isRequired(),
'typePieceId' => $requirement->getTypePiece()->getId(),
'typePiece' => $this->normalizeModelType($requirement->getTypePiece()),
'typePiece' => $this->normalizeModelType($requirement->getTypePiece()),
];
}
private function normalizeProductRequirement(TypeMachineProductRequirement $requirement): array
{
return [
'id' => $requirement->getId(),
'label' => $requirement->getLabel(),
'minCount' => $requirement->getMinCount(),
'maxCount' => $requirement->getMaxCount(),
'required' => $requirement->isRequired(),
'id' => $requirement->getId(),
'label' => $requirement->getLabel(),
'minCount' => $requirement->getMinCount(),
'maxCount' => $requirement->getMaxCount(),
'required' => $requirement->isRequired(),
'typeProductId' => $requirement->getTypeProduct()->getId(),
'typeProduct' => $this->normalizeModelType($requirement->getTypeProduct()),
'typeProduct' => $this->normalizeModelType($requirement->getTypeProduct()),
];
}
@@ -637,8 +637,8 @@ class MachineSkeletonController extends AbstractController
$items = [];
foreach ($constructeurs as $constructeur) {
$items[] = [
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(),
'phone' => $constructeur->getPhone(),
];
@@ -649,18 +649,18 @@ class MachineSkeletonController extends AbstractController
private function normalizeOverrides(object $link): ?array
{
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;
$reference = method_exists($link, 'getReferenceOverride') ? $link->getReferenceOverride() : null;
$prix = method_exists($link, 'getPrixOverride') ? $link->getPrixOverride() : null;
$prix = method_exists($link, 'getPrixOverride') ? $link->getPrixOverride() : null;
if ($name === null && $reference === null && $prix === null) {
if (null === $name && null === $reference && null === $prix) {
return null;
}
return [
'name' => $name,
'name' => $name,
'reference' => $reference,
'prix' => $prix,
'prix' => $prix,
];
}
@@ -683,12 +683,12 @@ class MachineSkeletonController extends AbstractController
private function stringOrNull(mixed $value): ?string
{
if ($value === null) {
if (null === $value) {
return null;
}
$string = trim((string) $value);
return $string === '' ? null : $string;
return '' === $string ? null : $string;
}
private function resolveIdentifier(array $entry, array $keys): ?string
@@ -698,9 +698,10 @@ class MachineSkeletonController extends AbstractController
continue;
}
$value = $entry[$key];
if ($value === null || $value === '') {
if (null === $value || '' === $value) {
continue;
}
return (string) $value;
}
@@ -709,6 +710,7 @@ class MachineSkeletonController extends AbstractController
/**
* @param array<array-key, object> $links
*
* @return array<string, object>
*/
private function indexLinksById(array $links): array
@@ -751,6 +753,6 @@ class MachineSkeletonController extends AbstractController
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\ModelTypeRepository;
use App\Service\ModelTypeCategoryConversionService;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ModelTypeConversionController
{
public function __construct(
private readonly ModelTypeRepository $modelTypes,
private readonly ModelTypeCategoryConversionService $conversionService,
) {}
#[Route('/api/model_types/{id}/conversion-check', name: 'api_model_type_conversion_check', methods: ['GET'])]
public function check(string $id): JsonResponse
{
$modelType = $this->modelTypes->find($id);
if (!$modelType) {
return new JsonResponse(
['message' => 'Catégorie introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
return new JsonResponse($this->conversionService->checkConversion($id));
}
#[Route('/api/model_types/{id}/convert', name: 'api_model_type_convert', methods: ['POST'])]
public function convert(string $id): JsonResponse
{
$modelType = $this->modelTypes->find($id);
if (!$modelType) {
return new JsonResponse(
['message' => 'Catégorie introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$result = $this->conversionService->convert($id);
if (!$result['success']) {
return new JsonResponse($result, Response::HTTP_CONFLICT);
}
return new JsonResponse($result);
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\PieceRepository;
use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -17,8 +18,7 @@ final class PieceHistoryController
private readonly PieceRepository $pieces,
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {
}
) {}
#[Route('/api/pieces/{id}/history', name: 'api_piece_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse
@@ -39,11 +39,11 @@ final class PieceHistoryController
))));
$actorMap = [];
if ($actorIds !== []) {
if ([] !== $actorIds) {
$profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') {
if ('' === $label) {
$label = $profile->getEmail() ?? $profile->getId();
}
$actorMap[$profile->getId()] = $label;
@@ -55,16 +55,16 @@ final class PieceHistoryController
$actorId = $log->getActorProfileId();
return [
'id' => $log->getId(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM),
'actor' => $actorId
'id' => $log->getId(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId
? [
'id' => $actorId,
'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId,
]
: null,
'diff' => $log->getDiff(),
'diff' => $log->getDiff(),
'snapshot' => $log->getSnapshot(),
];
},
@@ -77,4 +77,3 @@ final class PieceHistoryController
]);
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\ProductRepository;
use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -17,8 +18,7 @@ final class ProductHistoryController
private readonly ProductRepository $products,
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {
}
) {}
#[Route('/api/products/{id}/history', name: 'api_product_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse
@@ -39,11 +39,11 @@ final class ProductHistoryController
))));
$actorMap = [];
if ($actorIds !== []) {
if ([] !== $actorIds) {
$profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') {
if ('' === $label) {
$label = $profile->getEmail() ?? $profile->getId();
}
$actorMap[$profile->getId()] = $label;
@@ -55,16 +55,16 @@ final class ProductHistoryController
$actorId = $log->getActorProfileId();
return [
'id' => $log->getId(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM),
'actor' => $actorId
'id' => $log->getId(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId
? [
'id' => $actorId,
'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId,
]
: null,
'diff' => $log->getDiff(),
'diff' => $log->getDiff(),
'snapshot' => $log->getSnapshot(),
];
},
@@ -77,4 +77,3 @@ final class ProductHistoryController
]);
}
}

View File

@@ -12,9 +12,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class SessionProfileController
{
public function __construct(private readonly ProfileRepository $profiles)
{
}
public function __construct(private readonly ProfileRepository $profiles) {}
#[Route('/api/session/profile', name: 'api_session_profile_get', methods: ['GET'])]
public function getActiveProfile(Request $request): JsonResponse
@@ -32,16 +30,17 @@ final class SessionProfileController
$profile = $this->profiles->find($profileId);
if (!$profile || !$profile->isActive()) {
$session->remove('profileId');
return new JsonResponse(['message' => 'Profil introuvable ou inactif.'], JsonResponse::HTTP_UNAUTHORIZED);
}
return new JsonResponse([
'id' => $profile->getId(),
'id' => $profile->getId(),
'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(),
'email' => $profile->getEmail(),
'isActive' => $profile->isActive(),
'roles' => $profile->getRoles(),
'lastName' => $profile->getLastName(),
'email' => $profile->getEmail(),
'isActive' => $profile->isActive(),
'roles' => $profile->getRoles(),
]);
}
@@ -53,7 +52,7 @@ final class SessionProfileController
return new JsonResponse(['message' => 'Session indisponible.'], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
}
$payload = $request->toArray();
$payload = $request->toArray();
$profileId = $payload['profileId'] ?? null;
if (!$profileId) {
@@ -68,12 +67,12 @@ final class SessionProfileController
$session->set('profileId', $profile->getId());
return new JsonResponse([
'id' => $profile->getId(),
'id' => $profile->getId(),
'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(),
'email' => $profile->getEmail(),
'isActive' => $profile->isActive(),
'roles' => $profile->getRoles(),
'lastName' => $profile->getLastName(),
'email' => $profile->getEmail(),
'isActive' => $profile->isActive(),
'roles' => $profile->getRoles(),
]);
}

View File

@@ -16,8 +16,7 @@ final class SessionProfilesController
public function __construct(
private readonly ProfileRepository $profiles,
private readonly EntityManagerInterface $entityManager
) {
}
) {}
#[Route('/api/session/profiles', name: 'api_session_profiles_list', methods: ['GET'])]
public function list(): JsonResponse
@@ -27,7 +26,8 @@ final class SessionProfilesController
->setParameter('active', true)
->orderBy('p.firstName', 'ASC')
->getQuery()
->getResult();
->getResult()
;
return new JsonResponse(array_map([$this, 'serializeProfile'], $items));
}
@@ -35,11 +35,11 @@ final class SessionProfilesController
#[Route('/api/session/profiles', name: 'api_session_profiles_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$payload = $request->toArray();
$payload = $request->toArray();
$firstName = trim((string) ($payload['firstName'] ?? ''));
$lastName = trim((string) ($payload['lastName'] ?? ''));
$lastName = trim((string) ($payload['lastName'] ?? ''));
if ($firstName === '' || $lastName === '') {
if ('' === $firstName || '' === $lastName) {
return new JsonResponse(['message' => 'firstName et lastName sont requis.'], JsonResponse::HTTP_BAD_REQUEST);
}
@@ -71,10 +71,10 @@ final class SessionProfilesController
private function serializeProfile(Profile $profile): array
{
return [
'id' => $profile->getId(),
'id' => $profile->getId(),
'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(),
'isActive' => $profile->isActive(),
'lastName' => $profile->getLastName(),
'isActive' => $profile->isActive(),
];
}
}

View File

@@ -33,8 +33,8 @@ final class AlwaysQuoteStrategy implements QuoteStrategy
{
$tableName = $platform->quoteSingleIdentifier($class->table['name']);
if (! empty($class->table['schema'])) {
return $platform->quoteSingleIdentifier($class->table['schema']) . '.' . $tableName;
if (!empty($class->table['schema'])) {
return $platform->quoteSingleIdentifier($class->table['schema']).'.'.$tableName;
}
return $tableName;
@@ -56,10 +56,10 @@ final class AlwaysQuoteStrategy implements QuoteStrategy
$schema = '';
if (isset($association->joinTable->schema)) {
$schema = $platform->quoteSingleIdentifier($association->joinTable->schema) . '.';
$schema = $platform->quoteSingleIdentifier($association->joinTable->schema).'.';
}
return $schema . $platform->quoteSingleIdentifier($association->joinTable->name);
return $schema.$platform->quoteSingleIdentifier($association->joinTable->name);
}
public function getJoinColumnName(JoinColumnMapping $joinColumn, ClassMetadata $class, AbstractPlatform $platform): string
@@ -82,12 +82,13 @@ final class AlwaysQuoteStrategy implements QuoteStrategy
foreach ($class->identifier as $fieldName) {
if (isset($class->fieldMappings[$fieldName])) {
$quotedColumnNames[] = $this->getColumnName($fieldName, $class, $platform);
continue;
}
$assoc = $class->associationMappings[$fieldName];
assert($assoc->isToOneOwningSide());
$joinColumns = $assoc->joinColumns;
$joinColumns = $assoc->joinColumns;
$assocQuotedColumnNames = array_map(
static fn (JoinColumnMapping $joinColumn) => $platform->quoteSingleIdentifier($joinColumn->name),
$joinColumns,
@@ -103,8 +104,8 @@ final class AlwaysQuoteStrategy implements QuoteStrategy
string $columnName,
int $counter,
AbstractPlatform $platform,
ClassMetadata|null $class = null,
?ClassMetadata $class = null,
): string {
return $this->getSQLResultCasing($platform, $columnName . '_' . $counter);
return $this->getSQLResultCasing($platform, $columnName.'_'.$counter);
}
}

View File

@@ -49,11 +49,11 @@ class AuditLog
?array $snapshot = null,
?string $actorProfileId = null,
) {
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->action = $action;
$this->diff = $diff;
$this->snapshot = $snapshot;
$this->entityType = $entityType;
$this->entityId = $entityId;
$this->action = $action;
$this->diff = $diff;
$this->snapshot = $snapshot;
$this->actorProfileId = $actorProfileId;
}
@@ -64,7 +64,7 @@ class AuditLog
$this->createdAt = new DateTimeImmutable();
}
if ($this->id === null) {
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\MachineComponentLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@@ -65,26 +66,26 @@ class MachineComponentLink
private ?string $prixOverride = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt;
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt;
private DateTimeImmutable $updatedAt;
public function __construct()
{
$this->childLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection();
$this->childLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection();
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new \DateTimeImmutable();
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if ($this->id === null) {
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
@@ -92,12 +93,7 @@ class MachineComponentLink
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
@@ -195,4 +191,9 @@ class MachineComponentLink
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\MachinePieceLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@@ -53,10 +54,10 @@ class MachinePieceLink
private ?string $prixOverride = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt;
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt;
private DateTimeImmutable $updatedAt;
public function __construct()
{
@@ -66,11 +67,11 @@ class MachinePieceLink
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new \DateTimeImmutable();
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if ($this->id === null) {
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
@@ -78,12 +79,7 @@ class MachinePieceLink
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
@@ -181,4 +177,9 @@ class MachinePieceLink
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\MachineProductLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@@ -52,10 +53,10 @@ class MachineProductLink
private ?MachinePieceLink $parentPieceLink = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt;
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt;
private DateTimeImmutable $updatedAt;
public function __construct()
{
@@ -65,11 +66,11 @@ class MachineProductLink
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new \DateTimeImmutable();
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if ($this->id === null) {
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
@@ -77,12 +78,7 @@ class MachineProductLink
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
@@ -168,4 +164,9 @@ class MachineProductLink
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\TypeMachineComponentRequirementRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@@ -65,10 +66,10 @@ class TypeMachineComponentRequirement
private Collection $machineComponentLinks;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt;
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt;
private DateTimeImmutable $updatedAt;
public function __construct()
{
@@ -78,11 +79,11 @@ class TypeMachineComponentRequirement
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new \DateTimeImmutable();
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if ($this->id === null) {
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
@@ -90,12 +91,7 @@ class TypeMachineComponentRequirement
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
@@ -205,4 +201,9 @@ class TypeMachineComponentRequirement
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\TypeMachinePieceRequirementRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@@ -65,10 +66,10 @@ class TypeMachinePieceRequirement
private Collection $machinePieceLinks;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt;
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt;
private DateTimeImmutable $updatedAt;
public function __construct()
{
@@ -78,11 +79,11 @@ class TypeMachinePieceRequirement
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new \DateTimeImmutable();
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if ($this->id === null) {
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
@@ -90,12 +91,7 @@ class TypeMachinePieceRequirement
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
@@ -205,4 +201,9 @@ class TypeMachinePieceRequirement
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Repository\TypeMachineProductRequirementRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
@@ -65,10 +66,10 @@ class TypeMachineProductRequirement
private Collection $machineProductLinks;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt;
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt;
private DateTimeImmutable $updatedAt;
public function __construct()
{
@@ -78,11 +79,11 @@ class TypeMachineProductRequirement
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{
$now = new \DateTimeImmutable();
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
if ($this->id === null) {
if (null === $this->id) {
$this->id = $this->generateCuid();
}
}
@@ -90,12 +91,7 @@ class TypeMachineProductRequirement
#[ORM\PreUpdate]
public function setUpdatedAtValue(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
$this->updatedAt = new DateTimeImmutable();
}
public function getId(): ?string
@@ -205,4 +201,9 @@ class TypeMachineProductRequirement
return $this;
}
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
}

View File

@@ -7,6 +7,6 @@ namespace App\Enum;
enum ModelCategory: string
{
case COMPONENT = 'COMPONENT';
case PIECE = 'PIECE';
case PRODUCT = 'PRODUCT';
case PIECE = 'PIECE';
case PRODUCT = 'PRODUCT';
}

View File

@@ -16,9 +16,7 @@ use Doctrine\ORM\Events;
*/
final class PieceProductSyncSubscriber implements EventSubscriber
{
public function __construct(private readonly ProductRepository $productRepository)
{
}
public function __construct(private readonly ProductRepository $productRepository) {}
public function getSubscribedEvents(): array
{
@@ -47,7 +45,7 @@ final class PieceProductSyncSubscriber implements EventSubscriber
$this->syncPrimaryProduct($entity);
$em = $args->getObjectManager();
$em = $args->getObjectManager();
$meta = $em->getClassMetadata(Piece::class);
$em->getUnitOfWork()->recomputeSingleEntityChangeSet($meta, $entity);
}
@@ -56,7 +54,7 @@ final class PieceProductSyncSubscriber implements EventSubscriber
{
$productIds = $piece->getProductIds();
if ($productIds === []) {
if ([] === $productIds) {
// If no explicit list is provided, keep the legacy relation as-is.
return;
}
@@ -77,4 +75,3 @@ final class PieceProductSyncSubscriber implements EventSubscriber
$piece->setProduct($primaryProduct);
}
}

View File

@@ -9,6 +9,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Throwable;
final class UniqueConstraintSubscriber implements EventSubscriberInterface
{
@@ -30,15 +31,15 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
$event->setResponse(new JsonResponse(
[
'success' => false,
'error' => 'nom duplique',
'error' => 'nom duplique',
],
JsonResponse::HTTP_CONFLICT
));
}
private function findUniqueConstraintViolation(\Throwable $throwable): ?UniqueConstraintViolationException
private function findUniqueConstraintViolation(Throwable $throwable): ?UniqueConstraintViolationException
{
for ($current = $throwable; $current !== null; $current = $current->getPrevious()) {
for ($current = $throwable; null !== $current; $current = $current->getPrevious()) {
if ($current instanceof UniqueConstraintViolationException) {
return $current;
}

View File

@@ -0,0 +1,418 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enum\ModelCategory;
use App\Repository\ModelTypeRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Throwable;
final class ModelTypeCategoryConversionService
{
public function __construct(
private readonly Connection $connection,
private readonly ModelTypeRepository $modelTypes,
) {}
/**
* @return array{canConvert: bool, direction: null|string, itemCount: int, names: list<string>, blockers: list<string>}
*/
public function checkConversion(string $modelTypeId): array
{
$modelType = $this->modelTypes->find($modelTypeId);
if (!$modelType) {
return [
'canConvert' => false,
'direction' => null,
'itemCount' => 0,
'names' => [],
'blockers' => ['Catégorie introuvable.'],
];
}
$category = $modelType->getCategory();
if (ModelCategory::PRODUCT === $category) {
return [
'canConvert' => false,
'direction' => null,
'itemCount' => 0,
'names' => [],
'blockers' => ['La conversion n\'est pas disponible pour les catégories de produit.'],
];
}
if (ModelCategory::PIECE === $category) {
return $this->checkPieceToComponent($modelTypeId, $modelType->getName());
}
return $this->checkComponentToPiece($modelTypeId, $modelType->getName());
}
/**
* @return array{success: bool, convertedCount: int, error: null|string}
*/
public function convert(string $modelTypeId): array
{
$check = $this->checkConversion($modelTypeId);
if (!$check['canConvert']) {
return [
'success' => false,
'convertedCount' => 0,
'error' => implode(' ', $check['blockers']),
];
}
$modelType = $this->modelTypes->find($modelTypeId);
if (!$modelType) {
return ['success' => false, 'convertedCount' => 0, 'error' => 'Catégorie introuvable.'];
}
$category = $modelType->getCategory();
$this->connection->beginTransaction();
try {
if (ModelCategory::PIECE === $category) {
$count = $this->convertPieceToComponent($modelTypeId);
} else {
$count = $this->convertComponentToPiece($modelTypeId);
}
$this->connection->commit();
return ['success' => true, 'convertedCount' => $count, 'error' => null];
} catch (Throwable $e) {
$this->connection->rollBack();
return ['success' => false, 'convertedCount' => 0, 'error' => $e->getMessage()];
}
}
/**
* @return array{canConvert: bool, direction: string, itemCount: int, names: list<string>, blockers: list<string>}
*/
private function checkPieceToComponent(string $modelTypeId, string $modelTypeName): array
{
$blockers = [];
$pieceCount = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM pieces WHERE typepieceid = :id',
['id' => $modelTypeId],
);
$names = $this->connection->fetchFirstColumn(
'SELECT name FROM pieces WHERE typepieceid = :id ORDER BY name',
['id' => $modelTypeId],
);
// Check machine links
$machineLinked = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM machine_piece_links mpl
JOIN pieces p ON mpl.pieceid = p.id
WHERE p.typepieceid = :id',
['id' => $modelTypeId],
);
if ($machineLinked > 0) {
$blockers[] = sprintf('%d pièce(s) liée(s) à des machines.', $machineLinked);
}
// Check type machine requirements
$requirementCount = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM type_machine_piece_requirements WHERE typepieceid = :id',
['id' => $modelTypeId],
);
if ($requirementCount > 0) {
$blockers[] = sprintf('Utilisé dans %d modèle(s) de type de machine.', $requirementCount);
}
// Check name collision with existing composants
$collisions = $this->connection->fetchFirstColumn(
'SELECT p.name FROM pieces p
WHERE p.typepieceid = :id
AND p.name IN (SELECT c.name FROM composants c)',
['id' => $modelTypeId],
);
if ([] !== $collisions) {
$blockers[] = sprintf(
'Collision de nom avec des composants existants : %s.',
implode(', ', $collisions),
);
}
// Check ModelType name collision
$nameCollision = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM model_types WHERE category = :cat AND name = :name AND id != :id',
['cat' => ModelCategory::COMPONENT->value, 'name' => $modelTypeName, 'id' => $modelTypeId],
);
if ($nameCollision > 0) {
$blockers[] = sprintf('Une catégorie de composant « %s » existe déjà.', $modelTypeName);
}
return [
'canConvert' => [] === $blockers,
'direction' => 'piece_to_component',
'itemCount' => $pieceCount,
'names' => $names,
'blockers' => $blockers,
];
}
/**
* @return array{canConvert: bool, direction: string, itemCount: int, names: list<string>, blockers: list<string>}
*/
private function checkComponentToPiece(string $modelTypeId, string $modelTypeName): array
{
$blockers = [];
$composantCount = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM composants WHERE typecomposantid = :id',
['id' => $modelTypeId],
);
$names = $this->connection->fetchFirstColumn(
'SELECT name FROM composants WHERE typecomposantid = :id ORDER BY name',
['id' => $modelTypeId],
);
// Check machine links
$machineLinked = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM machine_component_links mcl
JOIN composants c ON mcl.composantid = c.id
WHERE c.typecomposantid = :id',
['id' => $modelTypeId],
);
if ($machineLinked > 0) {
$blockers[] = sprintf('%d composant(s) lié(s) à des machines.', $machineLinked);
}
// Check type machine requirements
$requirementCount = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM type_machine_component_requirements WHERE typecomposantid = :id',
['id' => $modelTypeId],
);
if ($requirementCount > 0) {
$blockers[] = sprintf('Utilisé dans %d modèle(s) de type de machine.', $requirementCount);
}
// Check if any composant has pieces or sub-components in structure
$withStructure = $this->connection->fetchAllAssociative(
'SELECT name, structure FROM composants WHERE typecomposantid = :id AND structure IS NOT NULL',
['id' => $modelTypeId],
);
foreach ($withStructure as $row) {
$structure = json_decode($row['structure'], true);
if (!is_array($structure)) {
continue;
}
$hasPieces = !empty($structure['pieces']);
$hasSubcomponents = !empty($structure['subcomponents']);
if ($hasPieces || $hasSubcomponents) {
$parts = [];
if ($hasPieces) {
$parts[] = 'pièces';
}
if ($hasSubcomponents) {
$parts[] = 'sous-composants';
}
$blockers[] = sprintf(
'Le composant « %s » contient des %s dans sa structure.',
$row['name'],
implode(' et ', $parts),
);
}
}
// Check name collision with existing pieces
$collisions = $this->connection->fetchFirstColumn(
'SELECT c.name FROM composants c
WHERE c.typecomposantid = :id
AND c.name IN (SELECT p.name FROM pieces p)',
['id' => $modelTypeId],
);
if ([] !== $collisions) {
$blockers[] = sprintf(
'Collision de nom avec des pièces existantes : %s.',
implode(', ', $collisions),
);
}
// Check ModelType name collision
$nameCollision = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM model_types WHERE category = :cat AND name = :name AND id != :id',
['cat' => ModelCategory::PIECE->value, 'name' => $modelTypeName, 'id' => $modelTypeId],
);
if ($nameCollision > 0) {
$blockers[] = sprintf('Une catégorie de pièce « %s » existe déjà.', $modelTypeName);
}
return [
'canConvert' => [] === $blockers,
'direction' => 'component_to_piece',
'itemCount' => $composantCount,
'names' => $names,
'blockers' => $blockers,
];
}
private function convertPieceToComponent(string $modelTypeId): int
{
// 1. Insert into composants from pieces
$count = $this->connection->executeStatement(
'INSERT INTO composants (id, name, reference, prix, structure, typecomposantid, productid, createdat, updatedat)
SELECT id, name, reference, prix, NULL, typepieceid, productid, createdat, updatedat
FROM pieces
WHERE typepieceid = :id',
['id' => $modelTypeId],
);
// 2. Transfer constructeur associations
$this->connection->executeStatement(
'INSERT INTO _composantconstructeurs (a, b)
SELECT pc.a, pc.b FROM _piececonstructeurs pc
WHERE pc.a IN (SELECT id FROM composants WHERE typecomposantid = :id)',
['id' => $modelTypeId],
);
$this->connection->executeStatement(
'DELETE FROM _piececonstructeurs
WHERE a IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $modelTypeId],
);
// 3. Transfer document references
$this->connection->executeStatement(
'UPDATE documents SET composantid = pieceid, pieceid = NULL
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $modelTypeId],
);
// 4. Transfer custom_field_values references
$this->connection->executeStatement(
'UPDATE custom_field_values SET composantid = pieceid, pieceid = NULL
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $modelTypeId],
);
// 5. Transfer custom_fields from typePiece to typeComposant
$this->connection->executeStatement(
'UPDATE custom_fields SET typecomposantid = typepieceid, typepieceid = NULL
WHERE typepieceid = :id',
['id' => $modelTypeId],
);
// 6. Delete original pieces
$this->connection->executeStatement(
'DELETE FROM pieces WHERE typepieceid = :id',
['id' => $modelTypeId],
);
// 7. Update ModelType
$this->connection->executeStatement(
'UPDATE model_types
SET category = :cat,
componentskeleton = pieceskeleton,
pieceskeleton = NULL,
updatedat = :now
WHERE id = :id',
[
'cat' => ModelCategory::COMPONENT->value,
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
'id' => $modelTypeId,
],
);
return $count;
}
private function convertComponentToPiece(string $modelTypeId): int
{
// 1. Insert into pieces from composants
$count = $this->connection->executeStatement(
'INSERT INTO pieces (id, name, reference, prix, productids, typepieceid, productid, createdat, updatedat)
SELECT id, name, reference, prix, NULL, typecomposantid, productid, createdat, updatedat
FROM composants
WHERE typecomposantid = :id',
['id' => $modelTypeId],
);
// 2. Transfer constructeur associations
$this->connection->executeStatement(
'INSERT INTO _piececonstructeurs (a, b)
SELECT cc.a, cc.b FROM _composantconstructeurs cc
WHERE cc.a IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $modelTypeId],
);
$this->connection->executeStatement(
'DELETE FROM _composantconstructeurs
WHERE a IN (SELECT id FROM composants WHERE typecomposantid = :id)',
['id' => $modelTypeId],
);
// 3. Transfer document references
$this->connection->executeStatement(
'UPDATE documents SET pieceid = composantid, composantid = NULL
WHERE composantid IN (SELECT id FROM composants WHERE typecomposantid = :id)',
['id' => $modelTypeId],
);
// 4. Transfer custom_field_values references
$this->connection->executeStatement(
'UPDATE custom_field_values SET pieceid = composantid, composantid = NULL
WHERE composantid IN (SELECT id FROM composants WHERE typecomposantid = :id)',
['id' => $modelTypeId],
);
// 5. Transfer custom_fields from typeComposant to typePiece
$this->connection->executeStatement(
'UPDATE custom_fields SET typepieceid = typecomposantid, typecomposantid = NULL
WHERE typecomposantid = :id',
['id' => $modelTypeId],
);
// 6. Delete original composants
$this->connection->executeStatement(
'DELETE FROM composants WHERE typecomposantid = :id',
['id' => $modelTypeId],
);
// 7. Update ModelType
$this->connection->executeStatement(
'UPDATE model_types
SET category = :cat,
pieceskeleton = componentskeleton,
componentskeleton = NULL,
updatedat = :now
WHERE id = :id',
[
'cat' => ModelCategory::PIECE->value,
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
'id' => $modelTypeId,
],
);
return $count;
}
}