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']);
+ }
}