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 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-05-06 10:14:23 +02:00
parent b16b619fc9
commit e432153083
8 changed files with 105 additions and 197 deletions

View File

@@ -739,12 +739,7 @@ watch(
) )
onMounted(() => { onMounted(() => {
pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
loadProducts().catch(() => {}) loadProducts().catch(() => {})
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
if (!props.piece.documents?.length) refreshDocuments() if (!props.piece.documents?.length) refreshDocuments()
}) })
</script> </script>

View File

@@ -119,7 +119,6 @@ export function useMachineDetailData(machineId: string) {
if (!machineName.value.trim()) return false if (!machineName.value.trim()) return false
return true return true
}) })
const debug = ref(false)
const componentsCollapsed = ref(true) const componentsCollapsed = ref(true)
const collapseToggleToken = ref(0) const collapseToggleToken = ref(0)
@@ -227,22 +226,6 @@ export function useMachineDetailData(machineId: string) {
const componentTypeOptions = computed(() => componentTypes.value || []) const componentTypeOptions = computed(() => componentTypes.value || [])
const pieceTypeOptions = computed(() => pieceTypes.value || []) const pieceTypeOptions = computed(() => pieceTypes.value || [])
const componentTypeLabelMap = computed(() => {
const map = new Map<string, string>()
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<string, string>()
pieceTypeOptions.value.forEach((type) => {
if (type?.id) map.set(type.id as string, (type.name as string) || '')
})
return map
})
// Machine field methods // Machine field methods
const initMachineFields = () => { const initMachineFields = () => {
if (machine.value) { if (machine.value) {
@@ -306,7 +289,6 @@ export function useMachineDetailData(machineId: string) {
// UI methods // UI methods
const toggleEditMode = () => { const toggleEditMode = () => {
isEditMode.value = !isEditMode.value isEditMode.value = !isEditMode.value
debug.value = !debug.value
if (isEditMode.value && !machineDocumentsLoaded.value) { if (isEditMode.value && !machineDocumentsLoaded.value) {
refreshMachineDocuments() refreshMachineDocuments()
} }
@@ -432,12 +414,6 @@ export function useMachineDetailData(machineId: string) {
await productsPromise await productsPromise
const linksApplied = applyMachineLinks(machineResult.data) 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) { if (!linksApplied) {
components.value = transformComponentCustomFields(machinePayload.components || []) components.value = transformComponentCustomFields(machinePayload.components || [])
pieces.value = transformCustomFields(machinePayload.pieces || []) pieces.value = transformCustomFields(machinePayload.pieces || [])
@@ -447,6 +423,8 @@ export function useMachineDetailData(machineId: string) {
} }
if (machine.value) { if (machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
machine.value.productLinks = machineProductLinks.value machine.value.productLinks = machineProductLinks.value
} }
@@ -496,11 +474,11 @@ export function useMachineDetailData(machineId: string) {
// UI state // UI state
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded, machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible, machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
isEditMode, debug, isEditMode,
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken, componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
// Computed // Computed
componentTypeOptions, pieceTypeOptions, componentTypeLabelMap, pieceTypeLabelMap, componentTypeOptions, pieceTypeOptions,
productInventory, productById, flattenedComponents, machinePieces, productInventory, productById, flattenedComponents, machinePieces,
machineDirectProducts, machineDocumentsList, visibleMachineCustomFields, machineDirectProducts, machineDocumentsList, visibleMachineCustomFields,

View File

@@ -162,9 +162,6 @@ class MachineStructureController extends AbstractController
// Copy product links // Copy product links
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap); $this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
// Copy context field values
$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
$this->entityManager->flush(); $this->entityManager->flush();
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']); $componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
@@ -230,6 +227,17 @@ class MachineStructureController extends AbstractController
$newLink->setReferenceOverride($link->getReferenceOverride()); $newLink->setReferenceOverride($link->getReferenceOverride());
$newLink->setPrixOverride($link->getPrixOverride()); $newLink->setPrixOverride($link->getPrixOverride());
$this->entityManager->persist($newLink); $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; $linkMap[$link->getId()] = $newLink;
} }
@@ -269,6 +277,17 @@ class MachineStructureController extends AbstractController
} }
$this->entityManager->persist($newLink); $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; $linkMap[$link->getId()] = $newLink;
} }
@@ -317,47 +336,6 @@ class MachineStructureController extends AbstractController
} }
} }
/**
* @param array<string, MachineComponentLink> $componentLinkMap
* @param array<string, MachinePieceLink> $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 private function normalizePayloadList(mixed $value): array
{ {
if (!is_array($value)) { if (!is_array($value)) {

View File

@@ -11,8 +11,8 @@ use App\Entity\Machine;
use App\Entity\ModelType; use App\Entity\ModelType;
use App\Entity\Piece; use App\Entity\Piece;
use App\Entity\Product; use App\Entity\Product;
use App\Entity\Profile;
use App\Entity\Site; use App\Entity\Site;
use App\Service\ActorProfileResolver;
use BackedEnum; use BackedEnum;
use DateTimeInterface; use DateTimeInterface;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -22,10 +22,6 @@ use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Doctrine\ORM\UnitOfWork; use Doctrine\ORM\UnitOfWork;
use Error; 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_array;
use function is_object; use function is_object;
@@ -35,8 +31,7 @@ use function method_exists;
abstract class AbstractAuditSubscriber implements EventSubscriber abstract class AbstractAuditSubscriber implements EventSubscriber
{ {
public function __construct( public function __construct(
private readonly RequestStack $requestStack, private readonly ActorProfileResolver $actorProfileResolver,
private readonly Security $security,
) {} ) {}
public function getSubscribedEvents(): array public function getSubscribedEvents(): array
@@ -61,7 +56,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
} }
} }
$actorProfileId = $this->resolveActorProfileId(); $actorProfileId = $this->actorProfileResolver->resolve();
$entityType = $this->entityType(); $entityType = $this->entityType();
if ($this->hasCollectionTracking()) { if ($this->hasCollectionTracking()) {
@@ -278,28 +273,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
return $entity->getVersion(); 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 private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
{ {
foreach ($uow->getScheduledEntityInsertions() as $entity) { 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)); $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
} }
foreach ($uow->getScheduledCollectionUpdates() as $collection) { // Note: scheduled collection updates/deletions are intentionally not
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities); // tracked here — constructeurs are now persisted as ConstructeurLink
} // entities (OneToMany), so Doctrine no longer fires collection events
foreach ($uow->getScheduledCollectionDeletions() as $collection) { // for them. Custom field values are handled below.
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities); $this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);
foreach ($pendingUpdates as $entityId => $diff) { 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( private function collectCustomFieldValueChanges(
UnitOfWork $uow, UnitOfWork $uow,
array &$pendingUpdates, array &$pendingUpdates,

View File

@@ -31,7 +31,7 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
} }
$uow = $em->getUnitOfWork(); $uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId(); $actorProfileId = $this->actorProfileResolver->resolve();
$this->processLinkChanges($em, $uow, $actorProfileId); $this->processLinkChanges($em, $uow, $actorProfileId);
} }

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Profile;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
final class ActorProfileResolver
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function resolve(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

@@ -34,7 +34,6 @@ use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use LogicException; use LogicException;
use Symfony\Component\HttpFoundation\RequestStack;
use Throwable; use Throwable;
final class EntityVersionService final class EntityVersionService
@@ -56,7 +55,7 @@ final class EntityVersionService
public function __construct( public function __construct(
private readonly AuditLogRepository $auditLogs, private readonly AuditLogRepository $auditLogs,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack, private readonly ActorProfileResolver $actorProfileResolver,
private readonly MachineRepository $machines, private readonly MachineRepository $machines,
private readonly ComposantRepository $composants, private readonly ComposantRepository $composants,
private readonly PieceRepository $pieces, private readonly PieceRepository $pieces,
@@ -187,7 +186,7 @@ final class EntityVersionService
'restore', 'restore',
['restoredFromVersion' => $version, 'restoreMode' => $restoreMode], ['restoredFromVersion' => $version, 'restoreMode' => $restoreMode],
$this->buildCurrentSnapshot($entityType, $entity), $this->buildCurrentSnapshot($entityType, $entity),
$this->resolveActorProfileId(), $this->actorProfileResolver->resolve(),
$newVersion, $newVersion,
); );
$this->em->persist($restoreAuditLog); $this->em->persist($restoreAuditLog);
@@ -917,25 +916,11 @@ final class EntityVersionService
'position' => $slot->getPosition(), 'position' => $slot->getPosition(),
]; ];
} }
$snapshot['productSlots'] = []; $snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots());
foreach ($entity->getProductSlots() as $slot) {
$snapshot['productSlots'][] = [
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
];
}
} }
if ('piece' === $entityType) { if ('piece' === $entityType) {
$snapshot['productSlots'] = []; $snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots());
foreach ($entity->getProductSlots() as $slot) {
$snapshot['productSlots'][] = [
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
];
}
} }
// Custom field values // Custom field values
@@ -953,21 +938,24 @@ final class EntityVersionService
} }
/** /**
* Resolve the current actor profile ID from the session. * @param iterable<ComposantProductSlot|PieceProductSlot> $slots
* Mirrors AbstractAuditSubscriber::resolveActorProfileId(). *
* @return list<array<string, mixed>>
*/ */
private function resolveActorProfileId(): ?string private function serializeProductSlots(iterable $slots): array
{ {
try { $serialized = [];
$session = $this->requestStack->getSession(); foreach ($slots as $slot) {
$profileId = $session->get('profileId'); $serialized[] = [
if ($profileId) { 'id' => $slot->getId(),
return (string) $profileId; 'typeProductId' => $slot->getTypeProduct()?->getId(),
} 'selectedProductId' => $slot->getSelectedProduct()?->getId(),
} catch (Throwable) { 'familyCode' => $slot->getFamilyCode(),
// No session available (CLI context, etc.) 'position' => $slot->getPosition(),
];
} }
return null; return $serialized;
} }
} }

View File

@@ -4,23 +4,17 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Entity\Profile;
use App\Enum\ModelCategory; use App\Enum\ModelCategory;
use App\Repository\ModelTypeRepository; use App\Repository\ModelTypeRepository;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\DBAL\Connection; 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 final class ModelTypeCategoryConversionService
{ {
public function __construct( public function __construct(
private readonly Connection $connection, private readonly Connection $connection,
private readonly ModelTypeRepository $modelTypes, private readonly ModelTypeRepository $modelTypes,
private readonly RequestStack $requestStack, private readonly ActorProfileResolver $actorProfileResolver,
private readonly Security $security,
) {} ) {}
/** /**
@@ -327,17 +321,7 @@ final class ModelTypeCategoryConversionService
); );
// 7. Update ModelType // 7. Update ModelType
$this->connection->executeStatement( $this->updateModelTypeCategory($modelTypeId, ModelCategory::COMPONENT);
'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,
],
);
return $count; return $count;
} }
@@ -406,19 +390,24 @@ final class ModelTypeCategoryConversionService
); );
// 7. Update ModelType // 7. Update ModelType
$this->updateModelTypeCategory($modelTypeId, ModelCategory::PIECE);
return $count;
}
private function updateModelTypeCategory(string $modelTypeId, ModelCategory $category): void
{
$this->connection->executeStatement( $this->connection->executeStatement(
'UPDATE model_types 'UPDATE model_types
SET category = :cat, SET category = :cat,
updatedat = :now updatedat = :now
WHERE id = :id', WHERE id = :id',
[ [
'cat' => ModelCategory::PIECE->value, 'cat' => $category->value,
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'), 'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
'id' => $modelTypeId, 'id' => $modelTypeId,
], ],
); );
return $count;
} }
/** /**
@@ -457,30 +446,10 @@ final class ModelTypeCategoryConversionService
'action' => 'convert', 'action' => 'convert',
'diff' => json_encode($diff), 'diff' => json_encode($diff),
'snapshot' => json_encode($snapshot), 'snapshot' => json_encode($snapshot),
'actor' => $this->resolveActorProfileId(), 'actor' => $this->actorProfileResolver->resolve(),
'now' => $now, '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;
}
} }