Compare commits

...

11 Commits

Author SHA1 Message Date
Matthieu
adc44b99d3 fix(machines) : fix skeleton creation — pagination, duplication, custom fields
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:40:09 +01:00
Matthieu
60afeb4cfd chore(frontend) : update submodule — Playwright e2e setup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 09:07:37 +01:00
Matthieu
02ff8b1a96 feat(audit) : extend audit logging to machines, constructeurs, model types, documents and conversions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:51:26 +01:00
Matthieu
2156df22c6 chore(release) : bump version to 1.6.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:27:47 +01:00
Matthieu
cd2a3fac55 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>
2026-02-12 14:27:07 +01:00
Matthieu
6300a3588a chore(docker) : replace pgAdmin with Adminer for lighter DB management
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 12:10:52 +01:00
Matthieu
45213103e4 Merge branch 'develop' into master — fix documents OOM 2026-02-11 17:16:41 +01:00
Matthieu
91b8b424d6 fix(documents) : add serialization groups to prevent OOM on collection endpoint
The path field (base64 data URIs) is now excluded from GetCollection
via document:list group. Individual GET returns path via document:detail
group. Related entities expose id+name in document:list for attachment
display. Frontend lazy-loads path on download/preview click.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:16:27 +01:00
Matthieu
0d1c9277e5 Merge branch 'develop' into master — changelog page 2026-02-11 17:01:53 +01:00
Matthieu
db16d26103 chore(frontend) : update submodule — changelog page
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:01:45 +01:00
Matthieu
0eb64d0975 Merge branch 'develop' into master — v1.5.0 2026-02-11 16:51:22 +01:00
36 changed files with 2002 additions and 398 deletions

View File

@@ -1 +1 @@
1.5.0 1.6.2

View File

@@ -45,34 +45,17 @@ services:
- "${POSTGRES_PORT:-5433}:5432" - "${POSTGRES_PORT:-5433}:5432"
restart: unless-stopped restart: unless-stopped
pgadmin: adminer:
container_name: pgadmin-${DOCKER_APP_NAME} container_name: adminer-${DOCKER_APP_NAME}
image: dpage/pgadmin4:latest image: adminer:latest
user: root
environment: environment:
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_EMAIL:-admin@admin.com} ADMINER_DEFAULT_SERVER: db
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_PASSWORD:-admin} ADMINER_DESIGN: dracula
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
PGADMIN_SERVER_JSON_FILE: '/pgadmin4/servers.json'
volumes:
- pgadmin_data:/var/lib/pgadmin
- ./docker/pgadmin/servers.json:/pgadmin4/servers.json:ro
- ./docker/pgadmin/pgpass:/pgadmin4/pgpass:ro
ports: ports:
- "${PGADMIN_PORT:-5050}:80" - "${ADMINER_PORT:-5050}:8080"
depends_on: depends_on:
- db - db
restart: unless-stopped restart: unless-stopped
entrypoint: >
/bin/sh -c "
mkdir -p /var/lib/pgadmin &&
cp /pgadmin4/pgpass /var/lib/pgadmin/pgpass &&
chmod 600 /var/lib/pgadmin/pgpass &&
chown 5050:5050 /var/lib/pgadmin/pgpass &&
/entrypoint.sh
"
volumes: volumes:
pg_data: pg_data:
pgadmin_data:

View File

@@ -7,6 +7,7 @@ namespace App\Controller;
use App\Repository\AuditLogRepository; use App\Repository\AuditLogRepository;
use App\Repository\ComposantRepository; use App\Repository\ComposantRepository;
use App\Repository\ProfileRepository; use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -17,8 +18,7 @@ final class ComposantHistoryController
private readonly ComposantRepository $components, private readonly ComposantRepository $components,
private readonly AuditLogRepository $auditLogs, private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles, private readonly ProfileRepository $profiles,
) { ) {}
}
#[Route('/api/composants/{id}/history', name: 'api_composant_history', methods: ['GET'])] #[Route('/api/composants/{id}/history', name: 'api_composant_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse public function __invoke(string $id): JsonResponse
@@ -39,11 +39,11 @@ final class ComposantHistoryController
)))); ))));
$actorMap = []; $actorMap = [];
if ($actorIds !== []) { if ([] !== $actorIds) {
$profiles = $this->profiles->findBy(['id' => $actorIds]); $profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) { foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName())); $label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') { if ('' === $label) {
$label = $profile->getEmail() ?? $profile->getId(); $label = $profile->getEmail() ?? $profile->getId();
} }
$actorMap[$profile->getId()] = $label; $actorMap[$profile->getId()] = $label;
@@ -55,16 +55,16 @@ final class ComposantHistoryController
$actorId = $log->getActorProfileId(); $actorId = $log->getActorProfileId();
return [ return [
'id' => $log->getId(), 'id' => $log->getId(),
'action' => $log->getAction(), 'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM), 'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId 'actor' => $actorId
? [ ? [
'id' => $actorId, 'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId, 'label' => $actorMap[$actorId] ?? $actorId,
] ]
: null, : null,
'diff' => $log->getDiff(), 'diff' => $log->getDiff(),
'snapshot' => $log->getSnapshot(), '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 ComposantRepository $composantRepository,
private readonly PieceRepository $pieceRepository, private readonly PieceRepository $pieceRepository,
private readonly ProductRepository $productRepository, private readonly ProductRepository $productRepository,
) { ) {}
}
#[Route('', name: 'custom_field_values_create', methods: ['POST'])] #[Route('', name: 'custom_field_values_create', methods: ['POST'])]
public function create(Request $request): JsonResponse public function create(Request $request): JsonResponse
@@ -80,7 +79,7 @@ class CustomFieldValueController extends AbstractController
} }
$existing = $this->customFieldValueRepository->findOneBy([ $existing = $this->customFieldValueRepository->findOneBy([
'customField' => $customField, 'customField' => $customField,
$target['type'] => $target['entity'], $target['type'] => $target['entity'],
]); ]);
@@ -107,7 +106,7 @@ class CustomFieldValueController extends AbstractController
{ {
$target = $this->resolveTarget([ $target = $this->resolveTarget([
'entityType' => $entityType, 'entityType' => $entityType,
'entityId' => $entityId, 'entityId' => $entityId,
]); ]);
if ($target instanceof JsonResponse) { if ($target instanceof JsonResponse) {
@@ -173,7 +172,7 @@ class CustomFieldValueController extends AbstractController
private function resolveCustomField(array $payload): CustomField|JsonResponse private function resolveCustomField(array $payload): CustomField|JsonResponse
{ {
$customFieldId = isset($payload['customFieldId']) ? trim((string) $payload['customFieldId']) : ''; $customFieldId = isset($payload['customFieldId']) ? trim((string) $payload['customFieldId']) : '';
if ($customFieldId !== '') { if ('' !== $customFieldId) {
$customField = $this->customFieldRepository->find($customFieldId); $customField = $this->customFieldRepository->find($customFieldId);
if ($customField instanceof CustomField) { if ($customField instanceof CustomField) {
return $customField; return $customField;
@@ -183,7 +182,7 @@ class CustomFieldValueController extends AbstractController
} }
$customFieldName = isset($payload['customFieldName']) ? trim((string) $payload['customFieldName']) : ''; $customFieldName = isset($payload['customFieldName']) ? trim((string) $payload['customFieldName']) : '';
if ($customFieldName === '') { if ('' === $customFieldName) {
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400); 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 private function resolveTarget(array $payload): array|JsonResponse
{ {
$entityType = isset($payload['entityType']) ? strtolower((string) $payload['entityType']) : ''; $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) { foreach (['machine', 'composant', 'piece', 'product'] as $candidate) {
$key = $candidate . 'Id'; $key = $candidate.'Id';
if (!isset($payload[$key])) { if (!isset($payload[$key])) {
continue; continue;
} }
$entityType = $candidate; $entityType = $candidate;
$entityId = trim((string) $payload[$key]); $entityId = trim((string) $payload[$key]);
break; break;
} }
} }
if ($entityType === '' || $entityId === '') { if ('' === $entityType || '' === $entityId) {
return $this->json(['success' => false, 'error' => 'Entity target is missing.'], 400); return $this->json(['success' => false, 'error' => 'Entity target is missing.'], 400);
} }
return match ($entityType) { return match ($entityType) {
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository), 'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository), 'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository),
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository), 'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
'product' => $this->resolveEntity('product', $entityId, $this->productRepository), 'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400), default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
}; };
} }
@@ -247,15 +247,22 @@ class CustomFieldValueController extends AbstractController
switch ($type) { switch ($type) {
case 'machine': case 'machine':
$value->setMachine($entity); $value->setMachine($entity);
break; break;
case 'composant': case 'composant':
$value->setComposant($entity); $value->setComposant($entity);
break; break;
case 'piece': case 'piece':
$value->setPiece($entity); $value->setPiece($entity);
break; break;
case 'product': case 'product':
$value->setProduct($entity); $value->setProduct($entity);
break; break;
} }
} }
@@ -265,23 +272,23 @@ class CustomFieldValueController extends AbstractController
$customField = $value->getCustomField(); $customField = $value->getCustomField();
return [ return [
'id' => $value->getId(), 'id' => $value->getId(),
'value' => $value->getValue(), 'value' => $value->getValue(),
'customFieldId' => $customField->getId(), 'customFieldId' => $customField->getId(),
'customField' => [ 'customField' => [
'id' => $customField->getId(), 'id' => $customField->getId(),
'name' => $customField->getName(), 'name' => $customField->getName(),
'type' => $customField->getType(), 'type' => $customField->getType(),
'required' => $customField->isRequired(), 'required' => $customField->isRequired(),
'options' => $customField->getOptions(), 'options' => $customField->getOptions(),
'orderIndex' => $customField->getOrderIndex(), 'orderIndex' => $customField->getOrderIndex(),
], ],
'machineId' => $value->getMachine()?->getId(), 'machineId' => $value->getMachine()?->getId(),
'composantId' => $value->getComposant()?->getId(), 'composantId' => $value->getComposant()?->getId(),
'pieceId' => $value->getPiece()?->getId(), 'pieceId' => $value->getPiece()?->getId(),
'productId' => $value->getProduct()?->getId(), 'productId' => $value->getProduct()?->getId(),
'createdAt' => $value->getCreatedAt()->format(DATE_ATOM), 'createdAt' => $value->getCreatedAt()->format(DATE_ATOM),
'updatedAt' => $value->getUpdatedAt()->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 ComposantRepository $composantRepository,
private readonly PieceRepository $pieceRepository, private readonly PieceRepository $pieceRepository,
private readonly ProductRepository $productRepository, private readonly ProductRepository $productRepository,
) { ) {}
}
#[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])] #[Route('/site/{id}', name: 'documents_by_site', methods: ['GET'])]
public function listBySite(string $id): JsonResponse public function listBySite(string $id): JsonResponse
@@ -100,19 +99,19 @@ class DocumentQueryController extends AbstractController
{ {
return array_map(static function (Document $document): array { return array_map(static function (Document $document): array {
return [ return [
'id' => $document->getId(), 'id' => $document->getId(),
'name' => $document->getName(), 'name' => $document->getName(),
'filename' => $document->getFilename(), 'filename' => $document->getFilename(),
'path' => $document->getPath(), 'path' => $document->getPath(),
'mimeType' => $document->getMimeType(), 'mimeType' => $document->getMimeType(),
'size' => $document->getSize(), 'size' => $document->getSize(),
'siteId' => $document->getSite()?->getId(), 'siteId' => $document->getSite()?->getId(),
'machineId' => $document->getMachine()?->getId(), 'machineId' => $document->getMachine()?->getId(),
'composantId' => $document->getComposant()?->getId(), 'composantId' => $document->getComposant()?->getId(),
'pieceId' => $document->getPiece()?->getId(), 'pieceId' => $document->getPiece()?->getId(),
'productId' => $document->getProduct()?->getId(), 'productId' => $document->getProduct()?->getId(),
'createdAt' => $document->getCreatedAt()->format(DATE_ATOM), 'createdAt' => $document->getCreatedAt()->format(DATE_ATOM),
'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM), 'updatedAt' => $document->getUpdatedAt()->format(DATE_ATOM),
]; ];
}, $documents); }, $documents);
} }

View File

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

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Repository\AuditLogRepository;
use App\Repository\MachineRepository;
use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class MachineHistoryController
{
public function __construct(
private readonly MachineRepository $machines,
private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles,
) {}
#[Route('/api/machines/{id}/history', name: 'api_machine_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse
{
$machine = $this->machines->find($id);
if (!$machine) {
return new JsonResponse(
['message' => 'Machine introuvable.'],
Response::HTTP_NOT_FOUND,
);
}
$logs = $this->auditLogs->findEntityHistory('machine', $id, 200);
$actorIds = array_values(array_unique(array_filter(array_map(
static fn ($log) => $log->getActorProfileId(),
$logs,
))));
$actorMap = [];
if ([] !== $actorIds) {
$profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ('' === $label) {
$label = $profile->getEmail() ?? $profile->getId();
}
$actorMap[$profile->getId()] = $label;
}
}
$items = array_map(
static function ($log) use ($actorMap) {
$actorId = $log->getActorProfileId();
return [
'id' => $log->getId(),
'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId
? [
'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId,
]
: null,
'diff' => $log->getDiff(),
'snapshot' => $log->getSnapshot(),
];
},
$logs,
);
return new JsonResponse([
'items' => array_values($items),
'total' => count($items),
]);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Composant; use App\Entity\Composant;
use App\Entity\CustomField;
use App\Entity\Machine; use App\Entity\Machine;
use App\Entity\MachineComponentLink; use App\Entity\MachineComponentLink;
use App\Entity\MachinePieceLink; use App\Entity\MachinePieceLink;
@@ -47,8 +48,7 @@ class MachineSkeletonController extends AbstractController
private readonly TypeMachineComponentRequirementRepository $componentRequirementRepository, private readonly TypeMachineComponentRequirementRepository $componentRequirementRepository,
private readonly TypeMachinePieceRequirementRepository $pieceRequirementRepository, private readonly TypeMachinePieceRequirementRepository $pieceRequirementRepository,
private readonly TypeMachineProductRequirementRepository $productRequirementRepository, private readonly TypeMachineProductRequirementRepository $productRequirementRepository,
) { ) {}
}
#[Route('/{id}/skeleton', name: 'machine_skeleton_get', methods: ['GET'])] #[Route('/{id}/skeleton', name: 'machine_skeleton_get', methods: ['GET'])]
public function getSkeleton(string $id): JsonResponse public function getSkeleton(string $id): JsonResponse
@@ -59,8 +59,8 @@ class MachineSkeletonController extends AbstractController
} }
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine]); $componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $machine]);
$pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]); $pieceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $machine]);
$productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]); $productLinks = $this->machineProductLinkRepository->findBy(['machine' => $machine]);
return $this->json($this->normalizeMachineSkeletonResponse( return $this->json($this->normalizeMachineSkeletonResponse(
$machine, $machine,
@@ -84,8 +84,8 @@ class MachineSkeletonController extends AbstractController
} }
$componentLinksPayload = $this->normalizePayloadList($payload['componentLinks'] ?? []); $componentLinksPayload = $this->normalizePayloadList($payload['componentLinks'] ?? []);
$pieceLinksPayload = $this->normalizePayloadList($payload['pieceLinks'] ?? []); $pieceLinksPayload = $this->normalizePayloadList($payload['pieceLinks'] ?? []);
$productLinksPayload = $this->normalizePayloadList($payload['productLinks'] ?? []); $productLinksPayload = $this->normalizePayloadList($payload['productLinks'] ?? []);
$componentLinks = $this->applyComponentLinks($machine, $componentLinksPayload); $componentLinks = $this->applyComponentLinks($machine, $componentLinksPayload);
if ($componentLinks instanceof JsonResponse) { if ($componentLinks instanceof JsonResponse) {
@@ -117,19 +117,20 @@ class MachineSkeletonController extends AbstractController
if (!is_array($value)) { if (!is_array($value)) {
return []; return [];
} }
return array_values(array_filter($value, static fn ($item) => is_array($item))); return array_values(array_filter($value, static fn ($item) => is_array($item)));
} }
private function applyComponentLinks(Machine $machine, array $payload): array|JsonResponse private function applyComponentLinks(Machine $machine, array $payload): array|JsonResponse
{ {
$existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine])); $existing = $this->indexLinksById($this->machineComponentLinkRepository->findBy(['machine' => $machine]));
$keepIds = []; $keepIds = [];
$pendingParents = []; $pendingParents = [];
$links = []; $links = [];
foreach ($payload as $entry) { foreach ($payload as $entry) {
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']); $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) { if (!$linkId) {
$linkId = $this->generateCuid(); $linkId = $this->generateCuid();
} }
@@ -167,7 +168,7 @@ class MachineSkeletonController extends AbstractController
$this->entityManager->persist($link); $this->entityManager->persist($link);
$links[$linkId] = $link; $links[$linkId] = $link;
$keepIds[] = $linkId; $keepIds[] = $linkId;
} }
foreach ($pendingParents as $linkId => $parentId) { foreach ($pendingParents as $linkId => $parentId) {
@@ -190,15 +191,15 @@ class MachineSkeletonController extends AbstractController
private function applyPieceLinks(Machine $machine, array $payload, array $componentLinks): array|JsonResponse 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); $componentIndex = $this->indexLinksById($componentLinks);
$keepIds = []; $keepIds = [];
$pendingParents = []; $pendingParents = [];
$links = []; $links = [];
foreach ($payload as $entry) { foreach ($payload as $entry) {
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']); $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) { if (!$linkId) {
$linkId = $this->generateCuid(); $linkId = $this->generateCuid();
} }
@@ -236,7 +237,7 @@ class MachineSkeletonController extends AbstractController
$this->entityManager->persist($link); $this->entityManager->persist($link);
$links[$linkId] = $link; $links[$linkId] = $link;
$keepIds[] = $linkId; $keepIds[] = $linkId;
} }
foreach ($pendingParents as $linkId => $parentId) { foreach ($pendingParents as $linkId => $parentId) {
@@ -263,16 +264,16 @@ class MachineSkeletonController extends AbstractController
array $componentLinks, array $componentLinks,
array $pieceLinks, array $pieceLinks,
): array|JsonResponse { ): array|JsonResponse {
$existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine])); $existing = $this->indexLinksById($this->machineProductLinkRepository->findBy(['machine' => $machine]));
$componentIndex = $this->indexLinksById($componentLinks); $componentIndex = $this->indexLinksById($componentLinks);
$pieceIndex = $this->indexLinksById($pieceLinks); $pieceIndex = $this->indexLinksById($pieceLinks);
$keepIds = []; $keepIds = [];
$pendingParents = []; $pendingParents = [];
$links = []; $links = [];
foreach ($payload as $entry) { foreach ($payload as $entry) {
$linkId = $this->resolveIdentifier($entry, ['id', 'linkId']); $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) { if (!$linkId) {
$linkId = $this->generateCuid(); $linkId = $this->generateCuid();
} }
@@ -302,13 +303,13 @@ class MachineSkeletonController extends AbstractController
$pendingParents[$linkId] = [ $pendingParents[$linkId] = [
'parentComponentLinkId' => $this->resolveIdentifier($entry, ['parentComponentLinkId']), 'parentComponentLinkId' => $this->resolveIdentifier($entry, ['parentComponentLinkId']),
'parentPieceLinkId' => $this->resolveIdentifier($entry, ['parentPieceLinkId']), 'parentPieceLinkId' => $this->resolveIdentifier($entry, ['parentPieceLinkId']),
'parentLinkId' => $this->resolveIdentifier($entry, ['parentLinkId']), 'parentLinkId' => $this->resolveIdentifier($entry, ['parentLinkId']),
]; ];
$this->entityManager->persist($link); $this->entityManager->persist($link);
$links[$linkId] = $link; $links[$linkId] = $link;
$keepIds[] = $linkId; $keepIds[] = $linkId;
} }
foreach ($pendingParents as $linkId => $parentIds) { foreach ($pendingParents as $linkId => $parentIds) {
@@ -338,26 +339,33 @@ class MachineSkeletonController extends AbstractController
array $productLinks, array $productLinks,
): array { ): array {
$normalizedComponentLinks = $this->normalizeComponentLinks($componentLinks); $normalizedComponentLinks = $this->normalizeComponentLinks($componentLinks);
$componentIndex = $this->indexNormalizedLinks($normalizedComponentLinks); $componentIndex = $this->indexNormalizedLinks($normalizedComponentLinks);
$normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks); $normalizedPieceLinks = $this->normalizePieceLinks($pieceLinks);
// Build component hierarchy // Build component hierarchy track which IDs are children
foreach ($normalizedComponentLinks as &$link) { $childIds = [];
foreach ($normalizedComponentLinks as $link) {
$parentId = $link['parentComponentLinkId'] ?? null; $parentId = $link['parentComponentLinkId'] ?? null;
if ($parentId && isset($componentIndex[$parentId])) { if ($parentId && isset($componentIndex[$parentId])) {
$componentIndex[$parentId]['childLinks'][] = &$link; $componentIndex[$parentId]['childLinks'][] = $link;
$childIds[$link['id']] = true;
} }
} }
unset($link);
// Add pieces to components recursively // Add pieces to components recursively
$this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks); $this->attachPiecesToComponents($componentIndex, $normalizedPieceLinks);
// Only return root-level components (exclude children already nested)
$rootComponents = array_filter(
$componentIndex,
static fn (array $link) => !isset($childIds[$link['id']]),
);
return [ return [
'machine' => $this->normalizeMachine($machine), 'machine' => $this->normalizeMachine($machine),
'componentLinks' => array_values($componentIndex), 'componentLinks' => array_values($rootComponents),
'pieceLinks' => $normalizedPieceLinks, 'pieceLinks' => $normalizedPieceLinks,
'productLinks' => $this->normalizeProductLinks($productLinks), 'productLinks' => $this->normalizeProductLinks($productLinks),
]; ];
} }
@@ -400,26 +408,26 @@ class MachineSkeletonController extends AbstractController
private function normalizeMachine(Machine $machine): array private function normalizeMachine(Machine $machine): array
{ {
$site = $machine->getSite(); $site = $machine->getSite();
$typeMachine = $machine->getTypeMachine(); $typeMachine = $machine->getTypeMachine();
return [ return [
'id' => $machine->getId(), 'id' => $machine->getId(),
'name' => $machine->getName(), 'name' => $machine->getName(),
'reference' => $machine->getReference(), 'reference' => $machine->getReference(),
'prix' => $machine->getPrix(), 'prix' => $machine->getPrix(),
'siteId' => $site->getId(), 'siteId' => $site->getId(),
'site' => [ 'site' => [
'id' => $site->getId(), 'id' => $site->getId(),
'name' => $site->getName(), 'name' => $site->getName(),
], ],
'typeMachineId' => $typeMachine?->getId(), 'typeMachineId' => $typeMachine?->getId(),
'typeMachine' => $typeMachine ? [ 'typeMachine' => $typeMachine ? [
'id' => $typeMachine->getId(), 'id' => $typeMachine->getId(),
'name' => $typeMachine->getName(), 'name' => $typeMachine->getName(),
'category' => $typeMachine->getCategory(), 'category' => $typeMachine->getCategory(),
'description' => $typeMachine->getDescription(), 'description' => $typeMachine->getDescription(),
'customFields' => $this->normalizeCustomFields($typeMachine->getCustomFields()), 'customFields' => $this->normalizeCustomFields($typeMachine->getCustomFields()),
'componentRequirements' => $typeMachine->getComponentRequirements() 'componentRequirements' => $typeMachine->getComponentRequirements()
->map(fn (TypeMachineComponentRequirement $req) => $this->normalizeComponentRequirement($req)) ->map(fn (TypeMachineComponentRequirement $req) => $this->normalizeComponentRequirement($req))
->toArray(), ->toArray(),
@@ -430,8 +438,8 @@ class MachineSkeletonController extends AbstractController
->map(fn (TypeMachineProductRequirement $req) => $this->normalizeProductRequirement($req)) ->map(fn (TypeMachineProductRequirement $req) => $this->normalizeProductRequirement($req))
->toArray(), ->toArray(),
] : null, ] : null,
'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()), 'constructeurs' => $this->normalizeConstructeurs($machine->getConstructeurs()),
'documents' => null, 'documents' => null,
'customFieldValues' => null, 'customFieldValues' => null,
]; ];
} }
@@ -444,13 +452,13 @@ class MachineSkeletonController extends AbstractController
continue; continue;
} }
$items[] = [ $items[] = [
'id' => $customField->getId(), 'id' => $customField->getId(),
'name' => $customField->getName(), 'name' => $customField->getName(),
'type' => $customField->getType(), 'type' => $customField->getType(),
'required' => $customField->isRequired(), 'required' => $customField->isRequired(),
'options' => $customField->getOptions(), 'options' => $customField->getOptions(),
'defaultValue' => $customField->getDefaultValue(), 'defaultValue' => $customField->getDefaultValue(),
'orderIndex' => $customField->getOrderIndex(), 'orderIndex' => $customField->getOrderIndex(),
]; ];
} }
@@ -460,26 +468,26 @@ class MachineSkeletonController extends AbstractController
private function normalizeComponentLinks(array $links): array private function normalizeComponentLinks(array $links): array
{ {
return array_map(function (MachineComponentLink $link): array { return array_map(function (MachineComponentLink $link): array {
$composant = $link->getComposant(); $composant = $link->getComposant();
$requirement = $link->getTypeMachineComponentRequirement(); $requirement = $link->getTypeMachineComponentRequirement();
$parentLink = $link->getParentLink(); $parentLink = $link->getParentLink();
$parentRequirementId = $parentLink?->getTypeMachineComponentRequirement()?->getId(); $parentRequirementId = $parentLink?->getTypeMachineComponentRequirement()?->getId();
return [ return [
'id' => $link->getId(), 'id' => $link->getId(),
'linkId' => $link->getId(), 'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(), 'machineId' => $link->getMachine()->getId(),
'composantId' => $composant->getId(), 'composantId' => $composant->getId(),
'composant' => $this->normalizeComposant($composant), 'composant' => $this->normalizeComposant($composant),
'typeMachineComponentRequirementId' => $requirement?->getId(), 'typeMachineComponentRequirementId' => $requirement?->getId(),
'typeMachineComponentRequirement' => $requirement ? $this->normalizeComponentRequirement($requirement) : null, 'typeMachineComponentRequirement' => $requirement ? $this->normalizeComponentRequirement($requirement) : null,
'parentLinkId' => $parentLink?->getId(), 'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(), 'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(), 'parentComponentId' => $parentLink?->getComposant()->getId(),
'parentMachineComponentRequirementId' => $parentRequirementId, 'parentMachineComponentRequirementId' => $parentRequirementId,
'overrides' => $this->normalizeOverrides($link), 'overrides' => $this->normalizeOverrides($link),
'childLinks' => [], 'childLinks' => [],
'pieceLinks' => [], 'pieceLinks' => [],
]; ];
}, $links); }, $links);
} }
@@ -487,24 +495,24 @@ class MachineSkeletonController extends AbstractController
private function normalizePieceLinks(array $links): array private function normalizePieceLinks(array $links): array
{ {
return array_map(function (MachinePieceLink $link): array { return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece(); $piece = $link->getPiece();
$requirement = $link->getTypeMachinePieceRequirement(); $requirement = $link->getTypeMachinePieceRequirement();
$parentLink = $link->getParentLink(); $parentLink = $link->getParentLink();
$parentRequirementId = $parentLink?->getTypeMachineComponentRequirement()?->getId(); $parentRequirementId = $parentLink?->getTypeMachineComponentRequirement()?->getId();
return [ return [
'id' => $link->getId(), 'id' => $link->getId(),
'linkId' => $link->getId(), 'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(), 'machineId' => $link->getMachine()->getId(),
'pieceId' => $piece->getId(), 'pieceId' => $piece->getId(),
'piece' => $this->normalizePiece($piece), 'piece' => $this->normalizePiece($piece),
'typeMachinePieceRequirementId' => $requirement?->getId(), 'typeMachinePieceRequirementId' => $requirement?->getId(),
'typeMachinePieceRequirement' => $requirement ? $this->normalizePieceRequirement($requirement) : null, 'typeMachinePieceRequirement' => $requirement ? $this->normalizePieceRequirement($requirement) : null,
'parentLinkId' => $parentLink?->getId(), 'parentLinkId' => $parentLink?->getId(),
'parentComponentLinkId' => $parentLink?->getId(), 'parentComponentLinkId' => $parentLink?->getId(),
'parentComponentId' => $parentLink?->getComposant()->getId(), 'parentComponentId' => $parentLink?->getComposant()->getId(),
'parentMachineComponentRequirementId' => $parentRequirementId, 'parentMachineComponentRequirementId' => $parentRequirementId,
'overrides' => $this->normalizeOverrides($link), 'overrides' => $this->normalizeOverrides($link),
]; ];
}, $links); }, $links);
} }
@@ -512,20 +520,20 @@ class MachineSkeletonController extends AbstractController
private function normalizeProductLinks(array $links): array private function normalizeProductLinks(array $links): array
{ {
return array_map(function (MachineProductLink $link): array { return array_map(function (MachineProductLink $link): array {
$product = $link->getProduct(); $product = $link->getProduct();
$requirement = $link->getTypeMachineProductRequirement(); $requirement = $link->getTypeMachineProductRequirement();
return [ return [
'id' => $link->getId(), 'id' => $link->getId(),
'linkId' => $link->getId(), 'linkId' => $link->getId(),
'machineId' => $link->getMachine()->getId(), 'machineId' => $link->getMachine()->getId(),
'productId' => $product->getId(), 'productId' => $product->getId(),
'product' => $this->normalizeProduct($product), 'product' => $this->normalizeProduct($product),
'typeMachineProductRequirementId' => $requirement?->getId(), 'typeMachineProductRequirementId' => $requirement?->getId(),
'typeMachineProductRequirement' => $requirement ? $this->normalizeProductRequirement($requirement) : null, 'typeMachineProductRequirement' => $requirement ? $this->normalizeProductRequirement($requirement) : null,
'parentLinkId' => $link->getParentLink()?->getId(), 'parentLinkId' => $link->getParentLink()?->getId(),
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(), 'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(), 'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
]; ];
}, $links); }, $links);
} }
@@ -533,49 +541,49 @@ class MachineSkeletonController extends AbstractController
private function normalizeComposant(Composant $composant): array private function normalizeComposant(Composant $composant): array
{ {
return [ return [
'id' => $composant->getId(), 'id' => $composant->getId(),
'name' => $composant->getName(), 'name' => $composant->getName(),
'reference' => $composant->getReference(), 'reference' => $composant->getReference(),
'prix' => $composant->getPrix(), 'prix' => $composant->getPrix(),
'typeComposantId' => $composant->getTypeComposant()?->getId(), 'typeComposantId' => $composant->getTypeComposant()?->getId(),
'typeComposant' => $this->normalizeModelType($composant->getTypeComposant()), 'typeComposant' => $this->normalizeModelType($composant->getTypeComposant()),
'productId' => $composant->getProduct()?->getId(), 'productId' => $composant->getProduct()?->getId(),
'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null, 'product' => $composant->getProduct() ? $this->normalizeProduct($composant->getProduct()) : null,
'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()), 'constructeurs' => $this->normalizeConstructeurs($composant->getConstructeurs()),
'documents' => [], 'documents' => [],
'customFields' => [], 'customFields' => [],
]; ];
} }
private function normalizePiece(Piece $piece): array private function normalizePiece(Piece $piece): array
{ {
return [ return [
'id' => $piece->getId(), 'id' => $piece->getId(),
'name' => $piece->getName(), 'name' => $piece->getName(),
'reference' => $piece->getReference(), 'reference' => $piece->getReference(),
'prix' => $piece->getPrix(), 'prix' => $piece->getPrix(),
'typePieceId' => $piece->getTypePiece()?->getId(), 'typePieceId' => $piece->getTypePiece()?->getId(),
'typePiece' => $this->normalizeModelType($piece->getTypePiece()), 'typePiece' => $this->normalizeModelType($piece->getTypePiece()),
'productId' => $piece->getProduct()?->getId(), 'productId' => $piece->getProduct()?->getId(),
'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null, 'product' => $piece->getProduct() ? $this->normalizeProduct($piece->getProduct()) : null,
'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()), 'constructeurs' => $this->normalizeConstructeurs($piece->getConstructeurs()),
'documents' => [], 'documents' => [],
'customFields' => [], 'customFields' => [],
]; ];
} }
private function normalizeProduct(Product $product): array private function normalizeProduct(Product $product): array
{ {
return [ return [
'id' => $product->getId(), 'id' => $product->getId(),
'name' => $product->getName(), 'name' => $product->getName(),
'reference' => $product->getReference(), 'reference' => $product->getReference(),
'supplierPrice' => $product->getSupplierPrice(), 'supplierPrice' => $product->getSupplierPrice(),
'typeProductId' => $product->getTypeProduct()?->getId(), 'typeProductId' => $product->getTypeProduct()?->getId(),
'typeProduct' => $this->normalizeModelType($product->getTypeProduct()), 'typeProduct' => $this->normalizeModelType($product->getTypeProduct()),
'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()), 'constructeurs' => $this->normalizeConstructeurs($product->getConstructeurs()),
'documents' => [], 'documents' => [],
'customFields' => [], 'customFields' => [],
]; ];
} }
@@ -586,9 +594,9 @@ class MachineSkeletonController extends AbstractController
} }
return [ return [
'id' => $type->getId(), 'id' => $type->getId(),
'name' => $type->getName(), 'name' => $type->getName(),
'code' => $type->getCode(), 'code' => $type->getCode(),
'category' => $type->getCategory()->value, 'category' => $type->getCategory()->value,
]; ];
} }
@@ -596,39 +604,39 @@ class MachineSkeletonController extends AbstractController
private function normalizeComponentRequirement(TypeMachineComponentRequirement $requirement): array private function normalizeComponentRequirement(TypeMachineComponentRequirement $requirement): array
{ {
return [ return [
'id' => $requirement->getId(), 'id' => $requirement->getId(),
'label' => $requirement->getLabel(), 'label' => $requirement->getLabel(),
'minCount' => $requirement->getMinCount(), 'minCount' => $requirement->getMinCount(),
'maxCount' => $requirement->getMaxCount(), 'maxCount' => $requirement->getMaxCount(),
'required' => $requirement->isRequired(), 'required' => $requirement->isRequired(),
'typeComposantId' => $requirement->getTypeComposant()->getId(), 'typeComposantId' => $requirement->getTypeComposant()->getId(),
'typeComposant' => $this->normalizeModelType($requirement->getTypeComposant()), 'typeComposant' => $this->normalizeModelType($requirement->getTypeComposant()),
]; ];
} }
private function normalizePieceRequirement(TypeMachinePieceRequirement $requirement): array private function normalizePieceRequirement(TypeMachinePieceRequirement $requirement): array
{ {
return [ return [
'id' => $requirement->getId(), 'id' => $requirement->getId(),
'label' => $requirement->getLabel(), 'label' => $requirement->getLabel(),
'minCount' => $requirement->getMinCount(), 'minCount' => $requirement->getMinCount(),
'maxCount' => $requirement->getMaxCount(), 'maxCount' => $requirement->getMaxCount(),
'required' => $requirement->isRequired(), 'required' => $requirement->isRequired(),
'typePieceId' => $requirement->getTypePiece()->getId(), 'typePieceId' => $requirement->getTypePiece()->getId(),
'typePiece' => $this->normalizeModelType($requirement->getTypePiece()), 'typePiece' => $this->normalizeModelType($requirement->getTypePiece()),
]; ];
} }
private function normalizeProductRequirement(TypeMachineProductRequirement $requirement): array private function normalizeProductRequirement(TypeMachineProductRequirement $requirement): array
{ {
return [ return [
'id' => $requirement->getId(), 'id' => $requirement->getId(),
'label' => $requirement->getLabel(), 'label' => $requirement->getLabel(),
'minCount' => $requirement->getMinCount(), 'minCount' => $requirement->getMinCount(),
'maxCount' => $requirement->getMaxCount(), 'maxCount' => $requirement->getMaxCount(),
'required' => $requirement->isRequired(), 'required' => $requirement->isRequired(),
'typeProductId' => $requirement->getTypeProduct()->getId(), 'typeProductId' => $requirement->getTypeProduct()->getId(),
'typeProduct' => $this->normalizeModelType($requirement->getTypeProduct()), 'typeProduct' => $this->normalizeModelType($requirement->getTypeProduct()),
]; ];
} }
@@ -637,8 +645,8 @@ class MachineSkeletonController extends AbstractController
$items = []; $items = [];
foreach ($constructeurs as $constructeur) { foreach ($constructeurs as $constructeur) {
$items[] = [ $items[] = [
'id' => $constructeur->getId(), 'id' => $constructeur->getId(),
'name' => $constructeur->getName(), 'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(), 'email' => $constructeur->getEmail(),
'phone' => $constructeur->getPhone(), 'phone' => $constructeur->getPhone(),
]; ];
@@ -649,18 +657,18 @@ class MachineSkeletonController extends AbstractController
private function normalizeOverrides(object $link): ?array 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; $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 null;
} }
return [ return [
'name' => $name, 'name' => $name,
'reference' => $reference, 'reference' => $reference,
'prix' => $prix, 'prix' => $prix,
]; ];
} }
@@ -683,12 +691,12 @@ class MachineSkeletonController extends AbstractController
private function stringOrNull(mixed $value): ?string private function stringOrNull(mixed $value): ?string
{ {
if ($value === null) { if (null === $value) {
return null; return null;
} }
$string = trim((string) $value); $string = trim((string) $value);
return $string === '' ? null : $string; return '' === $string ? null : $string;
} }
private function resolveIdentifier(array $entry, array $keys): ?string private function resolveIdentifier(array $entry, array $keys): ?string
@@ -698,9 +706,10 @@ class MachineSkeletonController extends AbstractController
continue; continue;
} }
$value = $entry[$key]; $value = $entry[$key];
if ($value === null || $value === '') { if (null === $value || '' === $value) {
continue; continue;
} }
return (string) $value; return (string) $value;
} }
@@ -709,6 +718,7 @@ class MachineSkeletonController extends AbstractController
/** /**
* @param array<array-key, object> $links * @param array<array-key, object> $links
*
* @return array<string, object> * @return array<string, object>
*/ */
private function indexLinksById(array $links): array private function indexLinksById(array $links): array
@@ -751,6 +761,6 @@ class MachineSkeletonController extends AbstractController
private function generateCuid(): string 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\AuditLogRepository;
use App\Repository\PieceRepository; use App\Repository\PieceRepository;
use App\Repository\ProfileRepository; use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -17,8 +18,7 @@ final class PieceHistoryController
private readonly PieceRepository $pieces, private readonly PieceRepository $pieces,
private readonly AuditLogRepository $auditLogs, private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles, private readonly ProfileRepository $profiles,
) { ) {}
}
#[Route('/api/pieces/{id}/history', name: 'api_piece_history', methods: ['GET'])] #[Route('/api/pieces/{id}/history', name: 'api_piece_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse public function __invoke(string $id): JsonResponse
@@ -39,11 +39,11 @@ final class PieceHistoryController
)))); ))));
$actorMap = []; $actorMap = [];
if ($actorIds !== []) { if ([] !== $actorIds) {
$profiles = $this->profiles->findBy(['id' => $actorIds]); $profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) { foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName())); $label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') { if ('' === $label) {
$label = $profile->getEmail() ?? $profile->getId(); $label = $profile->getEmail() ?? $profile->getId();
} }
$actorMap[$profile->getId()] = $label; $actorMap[$profile->getId()] = $label;
@@ -55,16 +55,16 @@ final class PieceHistoryController
$actorId = $log->getActorProfileId(); $actorId = $log->getActorProfileId();
return [ return [
'id' => $log->getId(), 'id' => $log->getId(),
'action' => $log->getAction(), 'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM), 'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId 'actor' => $actorId
? [ ? [
'id' => $actorId, 'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId, 'label' => $actorMap[$actorId] ?? $actorId,
] ]
: null, : null,
'diff' => $log->getDiff(), 'diff' => $log->getDiff(),
'snapshot' => $log->getSnapshot(), '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\AuditLogRepository;
use App\Repository\ProductRepository; use App\Repository\ProductRepository;
use App\Repository\ProfileRepository; use App\Repository\ProfileRepository;
use DateTimeInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
@@ -17,8 +18,7 @@ final class ProductHistoryController
private readonly ProductRepository $products, private readonly ProductRepository $products,
private readonly AuditLogRepository $auditLogs, private readonly AuditLogRepository $auditLogs,
private readonly ProfileRepository $profiles, private readonly ProfileRepository $profiles,
) { ) {}
}
#[Route('/api/products/{id}/history', name: 'api_product_history', methods: ['GET'])] #[Route('/api/products/{id}/history', name: 'api_product_history', methods: ['GET'])]
public function __invoke(string $id): JsonResponse public function __invoke(string $id): JsonResponse
@@ -39,11 +39,11 @@ final class ProductHistoryController
)))); ))));
$actorMap = []; $actorMap = [];
if ($actorIds !== []) { if ([] !== $actorIds) {
$profiles = $this->profiles->findBy(['id' => $actorIds]); $profiles = $this->profiles->findBy(['id' => $actorIds]);
foreach ($profiles as $profile) { foreach ($profiles as $profile) {
$label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName())); $label = trim(sprintf('%s %s', $profile->getFirstName(), $profile->getLastName()));
if ($label === '') { if ('' === $label) {
$label = $profile->getEmail() ?? $profile->getId(); $label = $profile->getEmail() ?? $profile->getId();
} }
$actorMap[$profile->getId()] = $label; $actorMap[$profile->getId()] = $label;
@@ -55,16 +55,16 @@ final class ProductHistoryController
$actorId = $log->getActorProfileId(); $actorId = $log->getActorProfileId();
return [ return [
'id' => $log->getId(), 'id' => $log->getId(),
'action' => $log->getAction(), 'action' => $log->getAction(),
'createdAt' => $log->getCreatedAt()->format(\DateTimeInterface::ATOM), 'createdAt' => $log->getCreatedAt()->format(DateTimeInterface::ATOM),
'actor' => $actorId 'actor' => $actorId
? [ ? [
'id' => $actorId, 'id' => $actorId,
'label' => $actorMap[$actorId] ?? $actorId, 'label' => $actorMap[$actorId] ?? $actorId,
] ]
: null, : null,
'diff' => $log->getDiff(), 'diff' => $log->getDiff(),
'snapshot' => $log->getSnapshot(), '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 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'])] #[Route('/api/session/profile', name: 'api_session_profile_get', methods: ['GET'])]
public function getActiveProfile(Request $request): JsonResponse public function getActiveProfile(Request $request): JsonResponse
@@ -32,16 +30,17 @@ final class SessionProfileController
$profile = $this->profiles->find($profileId); $profile = $this->profiles->find($profileId);
if (!$profile || !$profile->isActive()) { if (!$profile || !$profile->isActive()) {
$session->remove('profileId'); $session->remove('profileId');
return new JsonResponse(['message' => 'Profil introuvable ou inactif.'], JsonResponse::HTTP_UNAUTHORIZED); return new JsonResponse(['message' => 'Profil introuvable ou inactif.'], JsonResponse::HTTP_UNAUTHORIZED);
} }
return new JsonResponse([ return new JsonResponse([
'id' => $profile->getId(), 'id' => $profile->getId(),
'firstName' => $profile->getFirstName(), 'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(), 'lastName' => $profile->getLastName(),
'email' => $profile->getEmail(), 'email' => $profile->getEmail(),
'isActive' => $profile->isActive(), 'isActive' => $profile->isActive(),
'roles' => $profile->getRoles(), 'roles' => $profile->getRoles(),
]); ]);
} }
@@ -53,7 +52,7 @@ final class SessionProfileController
return new JsonResponse(['message' => 'Session indisponible.'], JsonResponse::HTTP_INTERNAL_SERVER_ERROR); return new JsonResponse(['message' => 'Session indisponible.'], JsonResponse::HTTP_INTERNAL_SERVER_ERROR);
} }
$payload = $request->toArray(); $payload = $request->toArray();
$profileId = $payload['profileId'] ?? null; $profileId = $payload['profileId'] ?? null;
if (!$profileId) { if (!$profileId) {
@@ -68,12 +67,12 @@ final class SessionProfileController
$session->set('profileId', $profile->getId()); $session->set('profileId', $profile->getId());
return new JsonResponse([ return new JsonResponse([
'id' => $profile->getId(), 'id' => $profile->getId(),
'firstName' => $profile->getFirstName(), 'firstName' => $profile->getFirstName(),
'lastName' => $profile->getLastName(), 'lastName' => $profile->getLastName(),
'email' => $profile->getEmail(), 'email' => $profile->getEmail(),
'isActive' => $profile->isActive(), 'isActive' => $profile->isActive(),
'roles' => $profile->getRoles(), 'roles' => $profile->getRoles(),
]); ]);
} }

View File

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

View File

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

View File

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

View File

@@ -30,11 +30,11 @@ class Composant
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)] #[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['composant:read'])] #[Groups(['composant:read', 'document:list'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)] #[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Groups(['composant:read'])] #[Groups(['composant:read', 'document:list'])]
private string $name; private string $name;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)] #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]

View File

@@ -5,6 +5,11 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Repository\DocumentRepository; use App\Repository\DocumentRepository;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
@@ -15,6 +20,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Table(name: 'documents')] #[ORM\Table(name: 'documents')]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ApiResource( #[ApiResource(
operations: [
new GetCollection(normalizationContext: ['groups' => ['document:list']]),
new Get(normalizationContext: ['groups' => ['document:list', 'document:detail']]),
new Post(),
new Put(),
new Delete(),
],
paginationClientItemsPerPage: true, paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200 paginationMaximumItemsPerPage: 200
)] )]
@@ -22,50 +34,56 @@ class Document
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)] #[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:read', 'composant:read', 'piece:read', 'product:read'])] #[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255)] #[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['document:read', 'composant:read', 'piece:read', 'product:read'])] #[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $name; private string $name;
#[ORM\Column(type: Types::STRING, length: 255)] #[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['document:read', 'composant:read', 'piece:read', 'product:read'])] #[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $filename; private string $filename;
#[ORM\Column(type: Types::TEXT)] #[ORM\Column(type: Types::TEXT)]
#[Groups(['document:read', 'composant:read', 'piece:read', 'product:read'])] #[Groups(['document:detail', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $path; private string $path;
#[ORM\Column(type: Types::STRING, length: 100, name: 'mimeType')] #[ORM\Column(type: Types::STRING, length: 100, name: 'mimeType')]
#[Groups(['document:read', 'composant:read', 'piece:read', 'product:read'])] #[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private string $mimeType; private string $mimeType;
#[ORM\Column(type: Types::INTEGER)] #[ORM\Column(type: Types::INTEGER)]
#[Groups(['document:read', 'composant:read', 'piece:read', 'product:read'])] #[Groups(['document:list', 'document:read', 'composant:read', 'piece:read', 'product:read'])]
private int $size; private int $size;
#[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'documents')] #[ORM\ManyToOne(targetEntity: Machine::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'machineId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Machine $machine = null; private ?Machine $machine = null;
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'documents')] #[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Composant $composant = null; private ?Composant $composant = null;
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'documents')] #[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Piece $piece = null; private ?Piece $piece = null;
#[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'documents')] #[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Product $product = null; private ?Product $product = null;
#[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'documents')] #[ORM\ManyToOne(targetEntity: Site::class, inversedBy: 'documents')]
#[ORM\JoinColumn(name: 'siteId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] #[ORM\JoinColumn(name: 'siteId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['document:list'])]
private ?Site $site = null; private ?Site $site = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['document:list'])]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]

View File

@@ -6,10 +6,12 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use App\Repository\MachineRepository; use App\Repository\MachineRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity(repositoryClass: MachineRepository::class)] #[ORM\Entity(repositoryClass: MachineRepository::class)]
#[ORM\Table(name: 'machines')] #[ORM\Table(name: 'machines')]
@@ -19,9 +21,11 @@ class Machine
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)] #[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)] #[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Groups(['document:list'])]
private string $name; private string $name;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)] #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
@@ -80,29 +84,29 @@ class Machine
private Collection $customFieldValues; private Collection $customFieldValues;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
public function __construct() public function __construct()
{ {
$this->constructeurs = new ArrayCollection(); $this->constructeurs = new ArrayCollection();
$this->componentLinks = new ArrayCollection(); $this->componentLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection(); $this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection(); $this->productLinks = new ArrayCollection();
$this->documents = new ArrayCollection(); $this->documents = new ArrayCollection();
$this->customFieldValues = new ArrayCollection(); $this->customFieldValues = new ArrayCollection();
} }
#[ORM\PrePersist] #[ORM\PrePersist]
public function setCreatedAtValue(): void public function setCreatedAtValue(): void
{ {
$now = new \DateTimeImmutable(); $now = new DateTimeImmutable();
$this->createdAt = $now; $this->createdAt = $now;
$this->updatedAt = $now; $this->updatedAt = $now;
if ($this->id === null) { if (null === $this->id) {
$this->id = $this->generateCuid(); $this->id = $this->generateCuid();
} }
} }
@@ -110,12 +114,7 @@ class Machine
#[ORM\PreUpdate] #[ORM\PreUpdate]
public function setUpdatedAtValue(): void public function setUpdatedAtValue(): void
{ {
$this->updatedAt = new \DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
} }
public function getId(): ?string public function getId(): ?string
@@ -238,13 +237,18 @@ class Machine
return $this->customFieldValues; return $this->customFieldValues;
} }
public function getCreatedAt(): \DateTimeImmutable public function getCreatedAt(): DateTimeImmutable
{ {
return $this->createdAt; return $this->createdAt;
} }
public function getUpdatedAt(): \DateTimeImmutable public function getUpdatedAt(): DateTimeImmutable
{ {
return $this->updatedAt; return $this->updatedAt;
} }
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
} }

View File

@@ -6,6 +6,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use App\Repository\MachineComponentLinkRepository; use App\Repository\MachineComponentLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
@@ -65,26 +66,26 @@ class MachineComponentLink
private ?string $prixOverride = null; private ?string $prixOverride = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
public function __construct() public function __construct()
{ {
$this->childLinks = new ArrayCollection(); $this->childLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection(); $this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection(); $this->productLinks = new ArrayCollection();
} }
#[ORM\PrePersist] #[ORM\PrePersist]
public function setCreatedAtValue(): void public function setCreatedAtValue(): void
{ {
$now = new \DateTimeImmutable(); $now = new DateTimeImmutable();
$this->createdAt = $now; $this->createdAt = $now;
$this->updatedAt = $now; $this->updatedAt = $now;
if ($this->id === null) { if (null === $this->id) {
$this->id = $this->generateCuid(); $this->id = $this->generateCuid();
} }
} }
@@ -92,12 +93,7 @@ class MachineComponentLink
#[ORM\PreUpdate] #[ORM\PreUpdate]
public function setUpdatedAtValue(): void public function setUpdatedAtValue(): void
{ {
$this->updatedAt = new \DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
} }
public function getId(): ?string public function getId(): ?string
@@ -195,4 +191,9 @@ class MachineComponentLink
return $this; 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 ApiPlatform\Metadata\ApiResource;
use App\Repository\MachinePieceLinkRepository; use App\Repository\MachinePieceLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
@@ -53,10 +54,10 @@ class MachinePieceLink
private ?string $prixOverride = null; private ?string $prixOverride = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
public function __construct() public function __construct()
{ {
@@ -66,11 +67,11 @@ class MachinePieceLink
#[ORM\PrePersist] #[ORM\PrePersist]
public function setCreatedAtValue(): void public function setCreatedAtValue(): void
{ {
$now = new \DateTimeImmutable(); $now = new DateTimeImmutable();
$this->createdAt = $now; $this->createdAt = $now;
$this->updatedAt = $now; $this->updatedAt = $now;
if ($this->id === null) { if (null === $this->id) {
$this->id = $this->generateCuid(); $this->id = $this->generateCuid();
} }
} }
@@ -78,12 +79,7 @@ class MachinePieceLink
#[ORM\PreUpdate] #[ORM\PreUpdate]
public function setUpdatedAtValue(): void public function setUpdatedAtValue(): void
{ {
$this->updatedAt = new \DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
} }
public function getId(): ?string public function getId(): ?string
@@ -181,4 +177,9 @@ class MachinePieceLink
return $this; 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 ApiPlatform\Metadata\ApiResource;
use App\Repository\MachineProductLinkRepository; use App\Repository\MachineProductLinkRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
@@ -52,10 +53,10 @@ class MachineProductLink
private ?MachinePieceLink $parentPieceLink = null; private ?MachinePieceLink $parentPieceLink = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private \DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private \DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
public function __construct() public function __construct()
{ {
@@ -65,11 +66,11 @@ class MachineProductLink
#[ORM\PrePersist] #[ORM\PrePersist]
public function setCreatedAtValue(): void public function setCreatedAtValue(): void
{ {
$now = new \DateTimeImmutable(); $now = new DateTimeImmutable();
$this->createdAt = $now; $this->createdAt = $now;
$this->updatedAt = $now; $this->updatedAt = $now;
if ($this->id === null) { if (null === $this->id) {
$this->id = $this->generateCuid(); $this->id = $this->generateCuid();
} }
} }
@@ -77,12 +78,7 @@ class MachineProductLink
#[ORM\PreUpdate] #[ORM\PreUpdate]
public function setUpdatedAtValue(): void public function setUpdatedAtValue(): void
{ {
$this->updatedAt = new \DateTimeImmutable(); $this->updatedAt = new DateTimeImmutable();
}
private function generateCuid(): string
{
return 'cl' . bin2hex(random_bytes(12));
} }
public function getId(): ?string public function getId(): ?string
@@ -168,4 +164,9 @@ class MachineProductLink
return $this; return $this;
} }
private function generateCuid(): string
{
return 'cl'.bin2hex(random_bytes(12));
}
} }

View File

@@ -30,11 +30,11 @@ class Piece
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)] #[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['piece:read'])] #[Groups(['piece:read', 'document:list'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)] #[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Groups(['piece:read'])] #[Groups(['piece:read', 'document:list'])]
private string $name; private string $name;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)] #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]

View File

@@ -30,11 +30,11 @@ class Product
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)] #[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['product:read'])] #[Groups(['product:read', 'document:list'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)] #[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Groups(['product:read'])] #[Groups(['product:read', 'document:list'])]
private string $name; private string $name;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)] #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]

View File

@@ -16,6 +16,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: SiteRepository::class)] #[ORM\Entity(repositoryClass: SiteRepository::class)]
@@ -36,10 +37,12 @@ class Site
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)] #[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['document:list'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255)] #[ORM\Column(type: Types::STRING, length: 255)]
#[Assert\NotBlank] #[Assert\NotBlank]
#[Groups(['document:list'])]
private string $name; private string $name;
#[ORM\Column(type: Types::STRING, length: 255, options: ['default' => ''], name: 'contactName')] #[ORM\Column(type: Types::STRING, length: 255, options: ['default' => ''], name: 'contactName')]

View File

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

View File

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

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\Constructeur;
use App\Entity\Profile;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_scalar;
#[AsDoctrineListener(event: Events::onFlush)]
final class ConstructeurAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Constructeur) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotConstructeur($entity);
$this->persistAuditLog($em, new AuditLog('constructeur', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Constructeur) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotConstructeur($entity);
$this->persistAuditLog($em, new AuditLog('constructeur', $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Constructeur) {
continue;
}
$snapshot = $this->snapshotConstructeur($entity);
$this->persistAuditLog($em, new AuditLog('constructeur', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotConstructeur(Constructeur $constructeur): array
{
return [
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(),
'phone' => $constructeur->getPhone(),
];
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
return (string) $value;
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\Composant;
use App\Entity\Document;
use App\Entity\Machine;
use App\Entity\Piece;
use App\Entity\Product;
use App\Entity\Profile;
use App\Entity\Site;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_object;
use function is_scalar;
use function method_exists;
#[AsDoctrineListener(event: Events::onFlush)]
final class DocumentAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Document) {
continue;
}
$snapshot = $this->snapshotDocument($entity);
$this->persistAuditLog($em, new AuditLog('document', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotDocument(Document $document): array
{
return [
'id' => $document->getId(),
'name' => $document->getName(),
'filename' => $document->getFilename(),
'mimeType' => $document->getMimeType(),
'size' => $document->getSize(),
'machine' => $this->normalizeValue($document->getMachine()),
'composant' => $this->normalizeValue($document->getComposant()),
'piece' => $this->normalizeValue($document->getPiece()),
'product' => $this->normalizeValue($document->getProduct()),
'site' => $this->normalizeValue($document->getSite()),
];
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof Machine || $value instanceof Composant || $value instanceof Piece || $value instanceof Product || $value instanceof Site) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
return (string) $value;
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

@@ -0,0 +1,412 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
use App\Entity\ModelType;
use App\Entity\Product;
use App\Entity\Profile;
use App\Entity\Site;
use App\Entity\TypeMachine;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\PersistentCollection;
use Doctrine\ORM\UnitOfWork;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array;
use function is_object;
use function is_scalar;
use function method_exists;
#[AsDoctrineListener(event: Events::onFlush)]
final class MachineAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
$pendingUpdates = [];
$pendingSnapshots = [];
$pendingMachines = [];
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof Machine) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotMachine($entity);
$this->persistAuditLog($em, new AuditLog('machine', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof Machine) {
continue;
}
$machineId = (string) $entity->getId();
if ('' === $machineId) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$pendingUpdates[$machineId] = $this->mergeDiffs($pendingUpdates[$machineId] ?? [], $diff);
$pendingSnapshots[$machineId] = $this->snapshotMachine($entity);
$pendingMachines[$machineId] = $entity;
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof Machine) {
continue;
}
$snapshot = $this->snapshotMachine($entity);
$this->persistAuditLog($em, new AuditLog('machine', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingMachines);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingMachines);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingMachines);
foreach ($pendingUpdates as $machineId => $diff) {
if ([] === $diff) {
continue;
}
$machine = $pendingMachines[$machineId] ?? null;
if (!$machine instanceof Machine) {
continue;
}
$snapshot = $pendingSnapshots[$machineId] ?? $this->snapshotMachine($machine);
$this->persistAuditLog($em, new AuditLog('machine', $machineId, 'update', $diff, $snapshot, $actorProfileId));
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Machine> $pendingMachines
*/
private function collectCollectionUpdate(
object $collection,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingMachines,
): void {
if (!$collection instanceof PersistentCollection) {
return;
}
$owner = $collection->getOwner();
if (!$owner instanceof Machine) {
return;
}
$machineId = (string) $owner->getId();
if ('' === $machineId) {
return;
}
$mapping = $collection->getMapping();
$fieldName = $mapping['fieldName'] ?? null;
if ('constructeurs' !== $fieldName) {
return;
}
$before = $this->normalizeCollection($collection->getSnapshot());
$after = $this->normalizeCollection($collection->toArray());
if ($before === $after) {
return;
}
$diff = [
'constructeurIds' => [
'from' => $before,
'to' => $after,
],
];
$pendingUpdates[$machineId] = $this->mergeDiffs($pendingUpdates[$machineId] ?? [], $diff);
$pendingSnapshots[$machineId] = $this->snapshotMachine($owner);
$pendingMachines[$machineId] = $owner;
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Machine> $pendingMachines
*/
private function collectCustomFieldValueChanges(
UnitOfWork $uow,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingMachines,
): void {
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, null, $entity->getValue(), $pendingUpdates, $pendingSnapshots, $pendingMachines);
}
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof CustomFieldValue) {
continue;
}
$changeSet = $uow->getEntityChangeSet($entity);
if (!isset($changeSet['value'])) {
continue;
}
[$oldVal, $newVal] = $changeSet['value'];
if ($oldVal !== $newVal) {
$this->trackCustomFieldValueChange($entity, $oldVal, $newVal, $pendingUpdates, $pendingSnapshots, $pendingMachines);
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if ($entity instanceof CustomFieldValue) {
$this->trackCustomFieldValueChange($entity, $entity->getValue(), null, $pendingUpdates, $pendingSnapshots, $pendingMachines);
}
}
}
/**
* @param array<string, array<string, array{from:mixed, to:mixed}>> $pendingUpdates
* @param array<string, array<string, mixed>> $pendingSnapshots
* @param array<string, Machine> $pendingMachines
*/
private function trackCustomFieldValueChange(
CustomFieldValue $cfv,
mixed $from,
mixed $to,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingMachines,
): void {
$owner = $cfv->getMachine();
if (!$owner instanceof Machine) {
return;
}
$ownerId = (string) $owner->getId();
if ('' === $ownerId) {
return;
}
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
$pendingSnapshots[$ownerId] = $this->snapshotMachine($owner);
$pendingMachines[$ownerId] = $owner;
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotMachine(Machine $machine): array
{
return [
'id' => $machine->getId(),
'name' => $machine->getName(),
'reference' => $machine->getReference(),
'prix' => $machine->getPrix(),
'site' => $this->normalizeValue($machine->getSite()),
'typeMachine' => $this->normalizeValue($machine->getTypeMachine()),
'constructeurIds' => $this->normalizeCollection($machine->getConstructeurs()),
];
}
/**
* @param iterable<mixed> $items
*
* @return list<array{id: string, name: string}|string>
*/
private function normalizeCollection(iterable $items): array
{
$entries = [];
$seen = [];
foreach ($items as $item) {
if (is_object($item) && method_exists($item, 'getId')) {
$id = $item->getId();
if (null === $id || '' === $id || isset($seen[(string) $id])) {
continue;
}
$seen[(string) $id] = true;
if (method_exists($item, 'getName')) {
$entries[] = ['id' => (string) $id, 'name' => (string) $item->getName()];
} else {
$entries[] = (string) $id;
}
}
}
return $entries;
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof Site) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
if ($value instanceof TypeMachine) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
];
}
if ($value instanceof ModelType) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'code' => $value->getCode(),
];
}
if ($value instanceof Product) {
return [
'id' => $value->getId(),
'name' => $value->getName(),
'reference' => $value->getReference(),
];
}
if ($value instanceof Collection) {
return $this->normalizeCollection($value);
}
if (is_object($value) && method_exists($value, 'getId')) {
return (string) $value->getId();
}
if (is_array($value)) {
return $value;
}
return (string) $value;
}
/**
* @param array<string, array{from:mixed, to:mixed}> $base
* @param array<string, array{from:mixed, to:mixed}> $extra
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function mergeDiffs(array $base, array $extra): array
{
foreach ($extra as $field => $change) {
$base[$field] = $change;
}
return $base;
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\AuditLog;
use App\Entity\ModelType;
use App\Entity\Profile;
use App\Enum\ModelCategory;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array;
use function is_scalar;
#[AsDoctrineListener(event: Events::onFlush)]
final class ModelTypeAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getObjectManager();
if (!$em instanceof EntityManagerInterface) {
return;
}
$uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId();
foreach ($uow->getScheduledEntityInsertions() as $entity) {
if (!$entity instanceof ModelType) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
$snapshot = $this->snapshotModelType($entity);
$this->persistAuditLog($em, new AuditLog('model_type', (string) $entity->getId(), 'create', $diff, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledEntityUpdates() as $entity) {
if (!$entity instanceof ModelType) {
continue;
}
$id = (string) $entity->getId();
if ('' === $id) {
continue;
}
$diff = $this->buildDiffFromChangeSet($uow->getEntityChangeSet($entity));
if ([] !== $diff) {
$snapshot = $this->snapshotModelType($entity);
$this->persistAuditLog($em, new AuditLog('model_type', $id, 'update', $diff, $snapshot, $actorProfileId));
}
}
foreach ($uow->getScheduledEntityDeletions() as $entity) {
if (!$entity instanceof ModelType) {
continue;
}
$snapshot = $this->snapshotModelType($entity);
$this->persistAuditLog($em, new AuditLog('model_type', (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
}
private function persistAuditLog(EntityManagerInterface $em, AuditLog $log): void
{
$uow = $em->getUnitOfWork();
$log->initializeAuditLog();
$em->persist($log);
$meta = $em->getClassMetadata(AuditLog::class);
$uow->computeChangeSet($meta, $log);
}
/**
* @param array<string, array{0:mixed, 1:mixed}> $changeSet
*
* @return array<string, array{from:mixed, to:mixed}>
*/
private function buildDiffFromChangeSet(array $changeSet): array
{
$diff = [];
foreach ($changeSet as $field => [$oldValue, $newValue]) {
if ('updatedAt' === $field || 'createdAt' === $field) {
continue;
}
$normalizedOld = $this->normalizeValue($oldValue);
$normalizedNew = $this->normalizeValue($newValue);
if ($normalizedOld === $normalizedNew) {
continue;
}
$diff[$field] = [
'from' => $normalizedOld,
'to' => $normalizedNew,
];
}
return $diff;
}
private function snapshotModelType(ModelType $modelType): array
{
return [
'id' => $modelType->getId(),
'name' => $modelType->getName(),
'code' => $modelType->getCode(),
'category' => $modelType->getCategory()->value,
'notes' => $modelType->getNotes(),
'description' => $modelType->getDescription(),
];
}
private function normalizeValue(mixed $value): mixed
{
if (null === $value || is_scalar($value)) {
return $value;
}
if ($value instanceof DateTimeInterface) {
return $value->format(DateTimeInterface::ATOM);
}
if ($value instanceof ModelCategory) {
return $value->value;
}
if (is_array($value)) {
return $value;
}
return (string) $value;
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

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

View File

@@ -9,6 +9,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\HttpKernel\KernelEvents;
use Throwable;
final class UniqueConstraintSubscriber implements EventSubscriberInterface final class UniqueConstraintSubscriber implements EventSubscriberInterface
{ {
@@ -30,15 +31,15 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
$event->setResponse(new JsonResponse( $event->setResponse(new JsonResponse(
[ [
'success' => false, 'success' => false,
'error' => 'nom duplique', 'error' => 'nom duplique',
], ],
JsonResponse::HTTP_CONFLICT 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) { if ($current instanceof UniqueConstraintViolationException) {
return $current; return $current;
} }

View File

@@ -0,0 +1,494 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Profile;
use App\Enum\ModelCategory;
use App\Repository\ModelTypeRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
final class ModelTypeCategoryConversionService
{
public function __construct(
private readonly Connection $connection,
private readonly ModelTypeRepository $modelTypes,
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
/**
* @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();
$direction = ModelCategory::PIECE === $category ? 'piece_to_component' : 'component_to_piece';
$names = $check['names'];
$modelName = $modelType->getName();
$modelCode = $modelType->getCode();
$this->connection->beginTransaction();
try {
if (ModelCategory::PIECE === $category) {
$count = $this->convertPieceToComponent($modelTypeId);
} else {
$count = $this->convertComponentToPiece($modelTypeId);
}
$this->logConversionAudit($modelTypeId, $modelName, $modelCode, $direction, $count, $names);
$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;
}
/**
* @param list<string> $names
*/
private function logConversionAudit(
string $modelTypeId,
string $modelName,
string $modelCode,
string $direction,
int $convertedCount,
array $names,
): void {
$now = new DateTimeImmutable()->format('Y-m-d H:i:s');
$id = 'cl'.bin2hex(random_bytes(12));
$snapshot = [
'id' => $modelTypeId,
'name' => $modelName,
'code' => $modelCode,
];
$diff = [
'direction' => ['from' => null, 'to' => $direction],
'convertedCount' => ['from' => null, 'to' => $convertedCount],
'convertedNames' => ['from' => null, 'to' => $names],
];
$this->connection->executeStatement(
'INSERT INTO audit_logs (id, entitytype, entityid, action, diff, snapshot, actorprofileid, createdat)
VALUES (:id, :entityType, :entityId, :action, :diff, :snapshot, :actor, :now)',
[
'id' => $id,
'entityType' => 'model_type',
'entityId' => $modelTypeId,
'action' => 'convert',
'diff' => json_encode($diff),
'snapshot' => json_encode($snapshot),
'actor' => $this->resolveActorProfileId(),
'now' => $now,
],
);
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}