From b16b619fc9ed2047185d0b27ef42b38af23d4d96 Mon Sep 17 00:00:00 2001
From: Matthieu
Date: Wed, 15 Apr 2026 08:28:37 +0200
Subject: [PATCH 1/6] docs : ajoute note delegation Codex pour taches
mecaniques
---
CLAUDE.md | 9 +++++++++
1 file changed, 9 insertions(+)
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.
From e432153083f72bf8de2180045f0225fc6f1ac222 Mon Sep 17 00:00:00 2001
From: Matthieu
Date: Wed, 6 May 2026 10:14:23 +0200
Subject: [PATCH 2/6] refactor : simplification globale (vague 1 + 2)
- ActorProfileResolver : service unique partage par AbstractAuditSubscriber, EntityVersionService et ModelTypeCategoryConversionService (3 implementations dupliquees+divergentes)
- corrige un bug latent : EntityVersionService restoraitsans le fallback Security::getUser, loggant actor=null hors session
- machine-clone : clonage des contextFieldValues integre dans cloneComponentLinks/clonePieceLinks, supprime cloneContextFieldValues et son find() en boucle
- helpers extraits : serializeProductSlots (EntityVersionService), updateModelTypeCategory (ModelTypeCategoryConversionService)
- supprime collectCollectionUpdate() vide + ses appels (AbstractAuditSubscriber)
- useMachineDetailData : retire debug ref couplee a isEditMode, componentTypeLabelMap/pieceTypeLabelMap jamais consommes, double assignation machine.productLinks
- PieceItem : retire l'init pieceData dans onMounted (deja couvert par reactive() et le watcher)
Co-Authored-By: Claude Opus 4.7
---
frontend/app/components/PieceItem.vue | 5 --
.../app/composables/useMachineDetailData.ts | 30 ++-------
src/Controller/MachineStructureController.php | 66 +++++++------------
.../AbstractAuditSubscriber.php | 55 ++--------------
.../MachineAuditSubscriber.php | 2 +-
src/Service/ActorProfileResolver.php | 41 ++++++++++++
src/Service/EntityVersionService.php | 50 ++++++--------
.../ModelTypeCategoryConversionService.php | 53 ++++-----------
8 files changed, 105 insertions(+), 197 deletions(-)
create mode 100644 src/Service/ActorProfileResolver.php
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/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..91627d3 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,
+ private 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..5e837d4 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,24 @@ 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..928f871 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,10 @@ 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;
- }
}
From 5c55441e6cc3816d0fdbaa762157cde468025208 Mon Sep 17 00:00:00 2001
From: Matthieu
Date: Wed, 6 May 2026 15:30:59 +0200
Subject: [PATCH 3/6] =?UTF-8?q?fix(audit)=20:=20visibilit=C3=A9=20protecte?=
=?UTF-8?q?d=20pour=20ActorProfileResolver?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
AbstractAuditSubscriber déclarait $actorProfileResolver en private readonly
via promoted property. MachineAuditSubscriber surcharge onFlush() et accède
à $this->actorProfileResolver, mais private n'est pas hérité — PHP voyait
null et levait "Call to a member function resolve() on null" sur chaque
flush Doctrine touchant des link entities.
Le passage à protected suit la convention déjà en place dans la classe
(safeGet, normalizeValue, persistAuditLog, etc. sont protected). readonly
préserve l'immutabilité de la dépendance DI.
Ajoute aussi deux tests de régression pour le clone des contextFieldValues
(symétrique au test composant existant) et nettoie deux lignes vides
cosmétiques laissées par le refactor précédent.
- testCloneMachineCopiesPieceContextFieldValues : vérifie que les CFV
context d'un MachinePieceLink sont bien rattachées au nouveau lien
après clone.
- testCloneMachineLeavesSourceContextFieldValuesIntact : vérifie que la
machine source garde ses CFV context après clone (invariant implicite).
---
.../AbstractAuditSubscriber.php | 2 +-
src/Service/EntityVersionService.php | 1 -
.../ModelTypeCategoryConversionService.php | 1 -
.../Entity/MachineContextCustomFieldTest.php | 105 ++++++++++++++++--
4 files changed, 95 insertions(+), 14 deletions(-)
diff --git a/src/EventSubscriber/AbstractAuditSubscriber.php b/src/EventSubscriber/AbstractAuditSubscriber.php
index 91627d3..a064f1f 100644
--- a/src/EventSubscriber/AbstractAuditSubscriber.php
+++ b/src/EventSubscriber/AbstractAuditSubscriber.php
@@ -31,7 +31,7 @@ use function method_exists;
abstract class AbstractAuditSubscriber implements EventSubscriber
{
public function __construct(
- private readonly ActorProfileResolver $actorProfileResolver,
+ protected readonly ActorProfileResolver $actorProfileResolver,
) {}
public function getSubscribedEvents(): array
diff --git a/src/Service/EntityVersionService.php b/src/Service/EntityVersionService.php
index 5e837d4..01da962 100644
--- a/src/Service/EntityVersionService.php
+++ b/src/Service/EntityVersionService.php
@@ -957,5 +957,4 @@ final class EntityVersionService
return $serialized;
}
-
}
diff --git a/src/Service/ModelTypeCategoryConversionService.php b/src/Service/ModelTypeCategoryConversionService.php
index 928f871..e30e941 100644
--- a/src/Service/ModelTypeCategoryConversionService.php
+++ b/src/Service/ModelTypeCategoryConversionService.php
@@ -451,5 +451,4 @@ final class ModelTypeCategoryConversionService
],
);
}
-
}
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']);
+ }
}
From 3ff89d43ede4a3ecf5d741140ecc5cbd574da08e Mon Sep 17 00:00:00 2001
From: Matthieu
Date: Wed, 6 May 2026 16:34:26 +0200
Subject: [PATCH 4/6] fix(db) : ajoute les FK CASCADE manquantes
documents.composantId et machine_component_links.composantId
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Les entités Doctrine déclaraient déjà onDelete: CASCADE pour ces deux
relations, mais les contraintes correspondantes étaient absentes en base.
Conséquence : la suppression d'un composant pouvait laisser des documents
ou des links machine orphelins. La migration nettoie les orphelins
existants (avec trace dans audit_logs) puis ajoute les deux FK.
---
...n20260506140000_FixComposantCascadeFKs.php | 106 ++++++++++++++++++
1 file changed, 106 insertions(+)
create mode 100644 migrations/Version20260506140000_FixComposantCascadeFKs.php
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');
+ }
+}
From 53d4d5768b8b130437ed35dc150d2f29ef153bb7 Mon Sep 17 00:00:00 2001
From: Matthieu
Date: Wed, 6 May 2026 16:40:37 +0200
Subject: [PATCH 5/6] refactor(doc) : utilise palier comme exemple plus parlant
que pompe
Remplace l'exemple "pompe avec position sur la machine" par un palier
de tete vs palier de pied : exemple plus concret et plus universellement
compris pour illustrer la difference entre champs catalogue et champs
contextuels (custom field values).
---
frontend/app/pages/doc.vue | 32 +++++++++++++++++---------------
1 file changed, 17 insertions(+), 15 deletions(-)
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)
From 27d51ffdb13448e2d2ef5c3c4f78bbf62dbf9627 Mon Sep 17 00:00:00 2001
From: Matthieu
Date: Wed, 6 May 2026 16:51:08 +0200
Subject: [PATCH 6/6] fix(toasts) : auto-dismiss des notifications d'erreur
apres 8 secondes
Les toasts d'erreur etaient persistants (duration force a 0) et restaient
affiches jusqu'a fermeture manuelle, ce qui pouvait empiler des messages
obsoletes a l'ecran. Aligne le comportement sur les autres types : duree
par defaut 8s (plus que warning a 6s pour laisser le temps de lire). Une
erreur critique peut toujours etre rendue persistante en passant
explicitement showError(msg, 0).
---
frontend/app/composables/useToast.ts | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
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 => {