diff --git a/CLAUDE.md b/CLAUDE.md index 003b324..66ca7b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -264,3 +264,12 @@ make test-setup # Créer/mettre à jour le schéma test - Nuxt dev : `http://localhost:3001` - Adminer (PG) : `http://localhost:5050` - PG direct : `localhost:5433` (user: root, pass: root, db: inventory) + +## Delegation Codex + +Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification. + +- **Codex** = junior dev rapide et pas cher (executions mecaniques) +- **Claude** = senior dev qui verifie et reflechit (design, review, decisions) + +C'est le meilleur ratio qualite/credits. diff --git a/frontend/app/components/PieceItem.vue b/frontend/app/components/PieceItem.vue index 71c9ea6..38b865b 100644 --- a/frontend/app/components/PieceItem.vue +++ b/frontend/app/components/PieceItem.vue @@ -739,12 +739,7 @@ watch( ) onMounted(() => { - pieceData.name = props.piece.name || '' - pieceData.reference = props.piece.reference || '' - pieceData.prix = props.piece.prix || '' - pieceData.quantity = props.piece.quantity ?? 1 loadProducts().catch(() => {}) - if (pieceData.productId) ensureProductLoaded(pieceData.productId) if (!props.piece.documents?.length) refreshDocuments() }) diff --git a/frontend/app/composables/useMachineDetailData.ts b/frontend/app/composables/useMachineDetailData.ts index 83c387c..d99c698 100644 --- a/frontend/app/composables/useMachineDetailData.ts +++ b/frontend/app/composables/useMachineDetailData.ts @@ -119,7 +119,6 @@ export function useMachineDetailData(machineId: string) { if (!machineName.value.trim()) return false return true }) - const debug = ref(false) const componentsCollapsed = ref(true) const collapseToggleToken = ref(0) @@ -227,22 +226,6 @@ export function useMachineDetailData(machineId: string) { const componentTypeOptions = computed(() => componentTypes.value || []) const pieceTypeOptions = computed(() => pieceTypes.value || []) - const componentTypeLabelMap = computed(() => { - const map = new Map() - componentTypeOptions.value.forEach((type) => { - if (type?.id) map.set(type.id as string, (type.name as string) || '') - }) - return map - }) - - const pieceTypeLabelMap = computed(() => { - const map = new Map() - pieceTypeOptions.value.forEach((type) => { - if (type?.id) map.set(type.id as string, (type.name as string) || '') - }) - return map - }) - // Machine field methods const initMachineFields = () => { if (machine.value) { @@ -306,7 +289,6 @@ export function useMachineDetailData(machineId: string) { // UI methods const toggleEditMode = () => { isEditMode.value = !isEditMode.value - debug.value = !debug.value if (isEditMode.value && !machineDocumentsLoaded.value) { refreshMachineDocuments() } @@ -432,12 +414,6 @@ export function useMachineDetailData(machineId: string) { await productsPromise const linksApplied = applyMachineLinks(machineResult.data) - if (machine.value) { - machine.value.componentLinks = machineComponentLinks.value - machine.value.pieceLinks = machinePieceLinks.value - machine.value.productLinks = machineProductLinks.value - } - if (!linksApplied) { components.value = transformComponentCustomFields(machinePayload.components || []) pieces.value = transformCustomFields(machinePayload.pieces || []) @@ -447,6 +423,8 @@ export function useMachineDetailData(machineId: string) { } if (machine.value) { + machine.value.componentLinks = machineComponentLinks.value + machine.value.pieceLinks = machinePieceLinks.value machine.value.productLinks = machineProductLinks.value } @@ -496,11 +474,11 @@ export function useMachineDetailData(machineId: string) { // UI state machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded, machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible, - isEditMode, debug, + isEditMode, componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken, // Computed - componentTypeOptions, pieceTypeOptions, componentTypeLabelMap, pieceTypeLabelMap, + componentTypeOptions, pieceTypeOptions, productInventory, productById, flattenedComponents, machinePieces, machineDirectProducts, machineDocumentsList, visibleMachineCustomFields, diff --git a/frontend/app/composables/useToast.ts b/frontend/app/composables/useToast.ts index 3b06474..7393d57 100644 --- a/frontend/app/composables/useToast.ts +++ b/frontend/app/composables/useToast.ts @@ -33,7 +33,7 @@ export function useToast() { message, type, visible: true, - duration: type === 'error' ? 0 : duration, + duration, } if (toasts.value.length >= MAX_TOASTS) { @@ -42,8 +42,7 @@ export function useToast() { toasts.value.push(toast) - // Only auto-dismiss non-error toasts - if (type !== 'error' && duration > 0) { + if (duration > 0) { setTimeout(() => { removeToast(id) }, duration) @@ -56,8 +55,8 @@ export function useToast() { return showToast(message, 'success', duration) } - const showError = (message: string): number => { - return showToast(message, 'error', 0) + const showError = (message: string, duration = 8000): number => { + return showToast(message, 'error', duration) } const showWarning = (message: string, duration = 6000): number => { diff --git a/frontend/app/pages/doc.vue b/frontend/app/pages/doc.vue index 406d8a5..1a8a800 100644 --- a/frontend/app/pages/doc.vue +++ b/frontend/app/pages/doc.vue @@ -715,10 +715,12 @@

Pourquoi ? Certaines informations n'ont de sens que quand - l'element est monte sur une machine. Par exemple, la "position sur la machine" - d'une pompe : dans le catalogue, la pompe n'est montee nulle part, donc ce champ - ne sert a rien. Mais quand on regarde cette pompe depuis la fiche d'une machine, - on veut savoir ou elle est installee. + l'element est monte sur une machine. Prenons l'exemple d'un palier : sur une + machine, vous en avez souvent deux, un en haut (le palier de tete) et un en + bas (le palier de pied). Dans le catalogue, le palier n'est monte nulle part, + donc savoir s'il est "en haut" ou "en bas" ne veut rien dire. Mais des qu'on + regarde ce palier depuis la fiche d'une machine, on veut savoir lequel des + deux c'est.

@@ -731,16 +733,16 @@

Quand on consulte l'element tout seul

- Debit max - 120 L/min + Diametre interieur + 50 mm
- ATEX - Oui + Type + Roulement a billes
- Position sur la machine + Emplacement pas affiche ici
@@ -753,17 +755,17 @@

Quand on regarde l'element dans sa machine

- Debit max - 120 L/min + Diametre interieur + 50 mm
- ATEX - Oui + Type + Roulement a billes
- Position sur la machine - Secteur B - Ligne 3 + Emplacement + Haut (palier de tete)
diff --git a/migrations/Version20260506140000_FixComposantCascadeFKs.php b/migrations/Version20260506140000_FixComposantCascadeFKs.php new file mode 100644 index 0000000..7b5a0e8 --- /dev/null +++ b/migrations/Version20260506140000_FixComposantCascadeFKs.php @@ -0,0 +1,106 @@ +addSql(<<<'SQL' + INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat) + SELECT + 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), + 'document', + d.id, + 'delete', + json_build_object( + 'id', d.id, + 'name', d.name, + 'filename', d.filename, + 'composantId', d.composantid, + 'note', 'Cleaned by FK cascade fix migration (Version20260506140000) - referenced composant no longer existed' + ), + NULL, + NOW() + FROM documents d + WHERE d.composantid IS NOT NULL + AND d.composantid NOT IN (SELECT id FROM composants) + SQL); + + $this->addSql(<<<'SQL' + INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat) + SELECT + 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), + 'machine_component_link', + l.id, + 'delete', + json_build_object( + 'id', l.id, + 'machineId', l.machineid, + 'composantId', l.composantid, + 'note', 'Cleaned by FK cascade fix migration (Version20260506140000) - referenced composant no longer existed' + ), + NULL, + NOW() + FROM machine_component_links l + WHERE l.composantid IS NOT NULL + AND l.composantid NOT IN (SELECT id FROM composants) + SQL); + + // 2. Nettoyage des orphelins. + $this->addSql(<<<'SQL' + DELETE FROM documents + WHERE composantid IS NOT NULL + AND composantid NOT IN (SELECT id FROM composants) + SQL); + + $this->addSql(<<<'SQL' + DELETE FROM machine_component_links + WHERE composantid IS NOT NULL + AND composantid NOT IN (SELECT id FROM composants) + SQL); + + // 3. Ajout idempotent des 2 FK manquantes (alignement avec les entités Doctrine). + $this->addSql(<<<'SQL' + DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_documents_composant' AND table_name = 'documents' + ) THEN + ALTER TABLE documents ADD CONSTRAINT fk_documents_composant + FOREIGN KEY (composantid) REFERENCES composants(id) ON DELETE CASCADE; + END IF; + END $$; + SQL); + + $this->addSql(<<<'SQL' + DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.table_constraints + WHERE constraint_name = 'fk_mcl_composant' AND table_name = 'machine_component_links' + ) THEN + ALTER TABLE machine_component_links ADD CONSTRAINT fk_mcl_composant + FOREIGN KEY (composantid) REFERENCES composants(id) ON DELETE CASCADE; + END IF; + END $$; + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_composant'); + $this->addSql('ALTER TABLE machine_component_links DROP CONSTRAINT IF EXISTS fk_mcl_composant'); + } +} diff --git a/src/Controller/MachineStructureController.php b/src/Controller/MachineStructureController.php index 7331a55..3a999b1 100644 --- a/src/Controller/MachineStructureController.php +++ b/src/Controller/MachineStructureController.php @@ -162,9 +162,6 @@ class MachineStructureController extends AbstractController // Copy product links $this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap); - // Copy context field values - $this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap); - $this->entityManager->flush(); $componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']); @@ -230,6 +227,17 @@ class MachineStructureController extends AbstractController $newLink->setReferenceOverride($link->getReferenceOverride()); $newLink->setPrixOverride($link->getPrixOverride()); $this->entityManager->persist($newLink); + + foreach ($link->getContextFieldValues() as $cfv) { + $newValue = new CustomFieldValue(); + $newValue->setCustomField($cfv->getCustomField()); + $newValue->setValue($cfv->getValue()); + $newValue->setMachineComponentLink($newLink); + $newValue->setComposant($newLink->getComposant()); + $this->entityManager->persist($newValue); + $newLink->getContextFieldValues()->add($newValue); + } + $linkMap[$link->getId()] = $newLink; } @@ -269,6 +277,17 @@ class MachineStructureController extends AbstractController } $this->entityManager->persist($newLink); + + foreach ($link->getContextFieldValues() as $cfv) { + $newValue = new CustomFieldValue(); + $newValue->setCustomField($cfv->getCustomField()); + $newValue->setValue($cfv->getValue()); + $newValue->setMachinePieceLink($newLink); + $newValue->setPiece($newLink->getPiece()); + $this->entityManager->persist($newValue); + $newLink->getContextFieldValues()->add($newValue); + } + $linkMap[$link->getId()] = $newLink; } @@ -317,47 +336,6 @@ class MachineStructureController extends AbstractController } } - /** - * @param array $componentLinkMap - * @param array $pieceLinkMap - */ - private function cloneContextFieldValues( - array $componentLinkMap, - array $pieceLinkMap, - ): void { - foreach ($componentLinkMap as $oldLinkId => $newLink) { - $oldLink = $this->machineComponentLinkRepository->find($oldLinkId); - if (!$oldLink) { - continue; - } - foreach ($oldLink->getContextFieldValues() as $cfv) { - $newValue = new CustomFieldValue(); - $newValue->setCustomField($cfv->getCustomField()); - $newValue->setValue($cfv->getValue()); - $newValue->setMachineComponentLink($newLink); - $newValue->setComposant($newLink->getComposant()); - $this->entityManager->persist($newValue); - $newLink->getContextFieldValues()->add($newValue); - } - } - - foreach ($pieceLinkMap as $oldLinkId => $newLink) { - $oldLink = $this->machinePieceLinkRepository->find($oldLinkId); - if (!$oldLink) { - continue; - } - foreach ($oldLink->getContextFieldValues() as $cfv) { - $newValue = new CustomFieldValue(); - $newValue->setCustomField($cfv->getCustomField()); - $newValue->setValue($cfv->getValue()); - $newValue->setMachinePieceLink($newLink); - $newValue->setPiece($newLink->getPiece()); - $this->entityManager->persist($newValue); - $newLink->getContextFieldValues()->add($newValue); - } - } - } - private function normalizePayloadList(mixed $value): array { if (!is_array($value)) { diff --git a/src/EventSubscriber/AbstractAuditSubscriber.php b/src/EventSubscriber/AbstractAuditSubscriber.php index 924375b..a064f1f 100644 --- a/src/EventSubscriber/AbstractAuditSubscriber.php +++ b/src/EventSubscriber/AbstractAuditSubscriber.php @@ -11,8 +11,8 @@ use App\Entity\Machine; use App\Entity\ModelType; use App\Entity\Piece; use App\Entity\Product; -use App\Entity\Profile; use App\Entity\Site; +use App\Service\ActorProfileResolver; use BackedEnum; use DateTimeInterface; use Doctrine\Common\Collections\Collection; @@ -22,10 +22,6 @@ use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Events; use Doctrine\ORM\UnitOfWork; use Error; -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; @@ -35,8 +31,7 @@ use function method_exists; abstract class AbstractAuditSubscriber implements EventSubscriber { public function __construct( - private readonly RequestStack $requestStack, - private readonly Security $security, + protected readonly ActorProfileResolver $actorProfileResolver, ) {} public function getSubscribedEvents(): array @@ -61,7 +56,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber } } - $actorProfileId = $this->resolveActorProfileId(); + $actorProfileId = $this->actorProfileResolver->resolve(); $entityType = $this->entityType(); if ($this->hasCollectionTracking()) { @@ -278,28 +273,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber return $entity->getVersion(); } - protected 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; - } - private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void { foreach ($uow->getScheduledEntityInsertions() as $entity) { @@ -385,13 +358,10 @@ abstract class AbstractAuditSubscriber implements EventSubscriber $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId)); } - foreach ($uow->getScheduledCollectionUpdates() as $collection) { - $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities); - } - foreach ($uow->getScheduledCollectionDeletions() as $collection) { - $this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities); - } - + // Note: scheduled collection updates/deletions are intentionally not + // tracked here — constructeurs are now persisted as ConstructeurLink + // entities (OneToMany), so Doctrine no longer fires collection events + // for them. Custom field values are handled below. $this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities); foreach ($pendingUpdates as $entityId => $diff) { @@ -411,17 +381,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber } } - /** - * No-op: constructeurs are now tracked as ConstructeurLink entities (OneToMany), - * so Doctrine no longer fires collection update events for them. - */ - private function collectCollectionUpdate( - object $collection, - array &$pendingUpdates, - array &$pendingSnapshots, - array &$pendingEntities, - ): void {} - private function collectCustomFieldValueChanges( UnitOfWork $uow, array &$pendingUpdates, diff --git a/src/EventSubscriber/MachineAuditSubscriber.php b/src/EventSubscriber/MachineAuditSubscriber.php index c19272d..1e1b358 100644 --- a/src/EventSubscriber/MachineAuditSubscriber.php +++ b/src/EventSubscriber/MachineAuditSubscriber.php @@ -31,7 +31,7 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber } $uow = $em->getUnitOfWork(); - $actorProfileId = $this->resolveActorProfileId(); + $actorProfileId = $this->actorProfileResolver->resolve(); $this->processLinkChanges($em, $uow, $actorProfileId); } diff --git a/src/Service/ActorProfileResolver.php b/src/Service/ActorProfileResolver.php new file mode 100644 index 0000000..0717150 --- /dev/null +++ b/src/Service/ActorProfileResolver.php @@ -0,0 +1,41 @@ +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; + } +} diff --git a/src/Service/EntityVersionService.php b/src/Service/EntityVersionService.php index 1fd2a6a..01da962 100644 --- a/src/Service/EntityVersionService.php +++ b/src/Service/EntityVersionService.php @@ -34,7 +34,6 @@ use DateTimeInterface; use Doctrine\ORM\EntityManagerInterface; use InvalidArgumentException; use LogicException; -use Symfony\Component\HttpFoundation\RequestStack; use Throwable; final class EntityVersionService @@ -56,7 +55,7 @@ final class EntityVersionService public function __construct( private readonly AuditLogRepository $auditLogs, private readonly EntityManagerInterface $em, - private readonly RequestStack $requestStack, + private readonly ActorProfileResolver $actorProfileResolver, private readonly MachineRepository $machines, private readonly ComposantRepository $composants, private readonly PieceRepository $pieces, @@ -187,7 +186,7 @@ final class EntityVersionService 'restore', ['restoredFromVersion' => $version, 'restoreMode' => $restoreMode], $this->buildCurrentSnapshot($entityType, $entity), - $this->resolveActorProfileId(), + $this->actorProfileResolver->resolve(), $newVersion, ); $this->em->persist($restoreAuditLog); @@ -917,25 +916,11 @@ final class EntityVersionService 'position' => $slot->getPosition(), ]; } - $snapshot['productSlots'] = []; - foreach ($entity->getProductSlots() as $slot) { - $snapshot['productSlots'][] = [ - 'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(), - 'selectedProductId' => $slot->getSelectedProduct()?->getId(), - 'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(), - ]; - } + $snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots()); } if ('piece' === $entityType) { - $snapshot['productSlots'] = []; - foreach ($entity->getProductSlots() as $slot) { - $snapshot['productSlots'][] = [ - 'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(), - 'selectedProductId' => $slot->getSelectedProduct()?->getId(), - 'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(), - ]; - } + $snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots()); } // Custom field values @@ -953,21 +938,23 @@ final class EntityVersionService } /** - * Resolve the current actor profile ID from the session. - * Mirrors AbstractAuditSubscriber::resolveActorProfileId(). + * @param iterable $slots + * + * @return list> */ - private function resolveActorProfileId(): ?string + private function serializeProductSlots(iterable $slots): array { - try { - $session = $this->requestStack->getSession(); - $profileId = $session->get('profileId'); - if ($profileId) { - return (string) $profileId; - } - } catch (Throwable) { - // No session available (CLI context, etc.) + $serialized = []; + foreach ($slots as $slot) { + $serialized[] = [ + 'id' => $slot->getId(), + 'typeProductId' => $slot->getTypeProduct()?->getId(), + 'selectedProductId' => $slot->getSelectedProduct()?->getId(), + 'familyCode' => $slot->getFamilyCode(), + 'position' => $slot->getPosition(), + ]; } - return null; + return $serialized; } } diff --git a/src/Service/ModelTypeCategoryConversionService.php b/src/Service/ModelTypeCategoryConversionService.php index 5856119..e30e941 100644 --- a/src/Service/ModelTypeCategoryConversionService.php +++ b/src/Service/ModelTypeCategoryConversionService.php @@ -4,23 +4,17 @@ 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, + private readonly ActorProfileResolver $actorProfileResolver, ) {} /** @@ -327,17 +321,7 @@ final class ModelTypeCategoryConversionService ); // 7. Update ModelType - $this->connection->executeStatement( - 'UPDATE model_types - SET category = :cat, - updatedat = :now - WHERE id = :id', - [ - 'cat' => ModelCategory::COMPONENT->value, - 'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'), - 'id' => $modelTypeId, - ], - ); + $this->updateModelTypeCategory($modelTypeId, ModelCategory::COMPONENT); return $count; } @@ -406,19 +390,24 @@ final class ModelTypeCategoryConversionService ); // 7. Update ModelType + $this->updateModelTypeCategory($modelTypeId, ModelCategory::PIECE); + + return $count; + } + + private function updateModelTypeCategory(string $modelTypeId, ModelCategory $category): void + { $this->connection->executeStatement( 'UPDATE model_types SET category = :cat, updatedat = :now WHERE id = :id', [ - 'cat' => ModelCategory::PIECE->value, + 'cat' => $category->value, 'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'), 'id' => $modelTypeId, ], ); - - return $count; } /** @@ -457,30 +446,9 @@ final class ModelTypeCategoryConversionService 'action' => 'convert', 'diff' => json_encode($diff), 'snapshot' => json_encode($snapshot), - 'actor' => $this->resolveActorProfileId(), + 'actor' => $this->actorProfileResolver->resolve(), '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; - } } diff --git a/tests/Api/Entity/MachineContextCustomFieldTest.php b/tests/Api/Entity/MachineContextCustomFieldTest.php index 05bf451..68f634b 100644 --- a/tests/Api/Entity/MachineContextCustomFieldTest.php +++ b/tests/Api/Entity/MachineContextCustomFieldTest.php @@ -7,6 +7,9 @@ namespace App\Tests\Api\Entity; use App\Enum\ModelCategory; use App\Tests\AbstractApiTestCase; +/** + * @internal + */ class MachineContextCustomFieldTest extends AbstractApiTestCase { public function testStructureReturnsContextFieldsOnComponentLink(): void @@ -56,7 +59,7 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase $normalFields = array_filter( $componentLink['composant']['customFields'], - fn (array $f) => $f['name'] === 'Serial', + fn (array $f) => 'Serial' === $f['name'], ); $this->assertCount(1, $normalFields); } @@ -65,8 +68,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase { $client = $this->createGestionnaireClient(); - $site = $this->createSite('Site B'); - $modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE); + $site = $this->createSite('Site B'); + $modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE); $contextField = $this->createCustomField( name: 'Wear Level', type: 'select', @@ -101,8 +104,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase { $client = $this->createGestionnaireClient(); - $site = $this->createSite('Site C'); - $modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT); + $site = $this->createSite('Site C'); + $modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT); $contextField = $this->createCustomField( name: 'Flow Rate', type: 'number', @@ -131,8 +134,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase { $client = $this->createGestionnaireClient(); - $site = $this->createSite('Site D'); - $modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT); + $site = $this->createSite('Site D'); + $modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT); $contextField = $this->createCustomField( name: 'Pressure', type: 'number', @@ -171,8 +174,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase { $client = $this->createGestionnaireClient(); - $site = $this->createSite('Site E'); - $modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT); + $site = $this->createSite('Site E'); + $modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT); $contextField = $this->createCustomField( name: 'Calibration Date', type: 'date', @@ -190,8 +193,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase { $client = $this->createGestionnaireClient(); - $site = $this->createSite('Site F'); - $modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT); + $site = $this->createSite('Site F'); + $modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT); $contextField = $this->createCustomField( name: 'RPM Setting', type: 'number', @@ -225,4 +228,84 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase $this->assertCount(1, $clonedLink['contextCustomFieldValues']); $this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']); } + + public function testCloneMachineCopiesPieceContextFieldValues(): void + { + $client = $this->createGestionnaireClient(); + + $site = $this->createSite('Site G'); + $modelType = $this->createModelType('Bearing Clone', 'BRGC', ModelCategory::PIECE); + $contextField = $this->createCustomField( + name: 'Wear Level', + type: 'text', + typePiece: $modelType, + machineContextOnly: true, + ); + + $source = $this->createMachine('Source Piece Machine', $site); + $piece = $this->createPiece('Bearing C', 'BRGC-001', $modelType); + $link = $this->createMachinePieceLink($source, $piece); + + $this->createCustomFieldValue( + customField: $contextField, + value: 'Fair', + piece: $piece, + machinePieceLink: $link, + ); + + $response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [ + 'json' => [ + 'name' => 'Cloned Piece Machine', + 'siteId' => $site->getId(), + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $data = $response->toArray(); + + $clonedLink = $data['pieceLinks'][0] ?? null; + $this->assertNotNull($clonedLink, 'Clone should expose at least one pieceLink'); + $this->assertCount(1, $clonedLink['contextCustomFieldValues']); + $this->assertSame('Fair', $clonedLink['contextCustomFieldValues'][0]['value']); + } + + public function testCloneMachineLeavesSourceContextFieldValuesIntact(): void + { + $client = $this->createGestionnaireClient(); + + $site = $this->createSite('Site H'); + $modelType = $this->createModelType('Motor Source', 'MOTS', ModelCategory::COMPONENT); + $contextField = $this->createCustomField( + name: 'RPM', + type: 'number', + typeComposant: $modelType, + machineContextOnly: true, + ); + + $source = $this->createMachine('Original Machine', $site); + $composant = $this->createComposant('Motor S', 'MOTS-001', $modelType); + $link = $this->createMachineComponentLink($source, $composant); + + $this->createCustomFieldValue( + customField: $contextField, + value: '1500', + composant: $composant, + machineComponentLink: $link, + ); + + $client->request('POST', '/api/machines/'.$source->getId().'/clone', [ + 'json' => [ + 'name' => 'Clone Machine', + 'siteId' => $site->getId(), + ], + ]); + $this->assertResponseStatusCodeSame(201); + + // Source must still expose its original context field value + $sourceData = $client->request('GET', '/api/machines/'.$source->getId().'/structure')->toArray(); + $sourceLink = $sourceData['componentLinks'][0] ?? null; + $this->assertNotNull($sourceLink, 'Source machine should still expose its component link'); + $this->assertCount(1, $sourceLink['contextCustomFieldValues']); + $this->assertSame('1500', $sourceLink['contextCustomFieldValues'][0]['value']); + } }