Compare commits

...

6 Commits

Author SHA1 Message Date
Matthieu
27d51ffdb1 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).
2026-05-06 16:51:08 +02:00
Matthieu
53d4d5768b 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).
2026-05-06 16:40:37 +02:00
Matthieu
3ff89d43ed fix(db) : ajoute les FK CASCADE manquantes documents.composantId et machine_component_links.composantId
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.
2026-05-06 16:34:26 +02:00
Matthieu
5c55441e6c fix(audit) : visibilité protected pour ActorProfileResolver
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).
2026-05-06 15:30:59 +02:00
Matthieu
e432153083 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>
2026-05-06 10:14:23 +02:00
Matthieu
b16b619fc9 docs : ajoute note delegation Codex pour taches mecaniques 2026-05-06 09:52:08 +02:00
13 changed files with 334 additions and 229 deletions

View File

@@ -264,3 +264,12 @@ make test-setup # Créer/mettre à jour le schéma test
- Nuxt dev : `http://localhost:3001` - Nuxt dev : `http://localhost:3001`
- Adminer (PG) : `http://localhost:5050` - Adminer (PG) : `http://localhost:5050`
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory) - 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.

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

@@ -33,7 +33,7 @@ export function useToast() {
message, message,
type, type,
visible: true, visible: true,
duration: type === 'error' ? 0 : duration, duration,
} }
if (toasts.value.length >= MAX_TOASTS) { if (toasts.value.length >= MAX_TOASTS) {
@@ -42,8 +42,7 @@ export function useToast() {
toasts.value.push(toast) toasts.value.push(toast)
// Only auto-dismiss non-error toasts if (duration > 0) {
if (type !== 'error' && duration > 0) {
setTimeout(() => { setTimeout(() => {
removeToast(id) removeToast(id)
}, duration) }, duration)
@@ -56,8 +55,8 @@ export function useToast() {
return showToast(message, 'success', duration) return showToast(message, 'success', duration)
} }
const showError = (message: string): number => { const showError = (message: string, duration = 8000): number => {
return showToast(message, 'error', 0) return showToast(message, 'error', duration)
} }
const showWarning = (message: string, duration = 6000): number => { const showWarning = (message: string, duration = 6000): number => {

View File

@@ -715,10 +715,12 @@
</p> </p>
<p class="text-base-content/70 leading-relaxed mb-4"> <p class="text-base-content/70 leading-relaxed mb-4">
<strong>Pourquoi ?</strong> Certaines informations n'ont de sens que quand <strong>Pourquoi ?</strong> Certaines informations n'ont de sens que quand
l'element est monte sur une machine. Par exemple, la "position sur la machine" l'element est monte sur une machine. Prenons l'exemple d'un palier : sur une
d'une pompe : dans le catalogue, la pompe n'est montee nulle part, donc ce champ machine, vous en avez souvent deux, un en haut (le palier de tete) et un en
ne sert a rien. Mais quand on regarde cette pompe depuis la fiche d'une machine, bas (le palier de pied). Dans le catalogue, le palier n'est monte nulle part,
on veut savoir ou elle est installee. 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.
</p> </p>
</div> </div>
</div> </div>
@@ -731,16 +733,16 @@
<p class="text-xs text-base-content/40 mb-4">Quand on consulte l'element tout seul</p> <p class="text-xs text-base-content/40 mb-4">Quand on consulte l'element tout seul</p>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-base-content/70">Debit max</span> <span class="text-base-content/70">Diametre interieur</span>
<span>120 L/min</span> <span>50 mm</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-base-content/70">ATEX</span> <span class="text-base-content/70">Type</span>
<span>Oui</span> <span>Roulement a billes</span>
</div> </div>
<div class="border-t border-dashed border-base-300 pt-2 mt-2"> <div class="border-t border-dashed border-base-300 pt-2 mt-2">
<div class="flex justify-between opacity-30"> <div class="flex justify-between opacity-30">
<span class="line-through">Position sur la machine</span> <span class="line-through">Emplacement</span>
<span class="text-xs italic">pas affiche ici</span> <span class="text-xs italic">pas affiche ici</span>
</div> </div>
</div> </div>
@@ -753,17 +755,17 @@
<p class="text-xs text-base-content/40 mb-4">Quand on regarde l'element dans sa machine</p> <p class="text-xs text-base-content/40 mb-4">Quand on regarde l'element dans sa machine</p>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-base-content/70">Debit max</span> <span class="text-base-content/70">Diametre interieur</span>
<span>120 L/min</span> <span>50 mm</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-base-content/70">ATEX</span> <span class="text-base-content/70">Type</span>
<span>Oui</span> <span>Roulement a billes</span>
</div> </div>
<div class="border-t border-primary/20 pt-2 mt-2"> <div class="border-t border-primary/20 pt-2 mt-2">
<div class="flex justify-between bg-primary/10 rounded px-2 py-1.5"> <div class="flex justify-between bg-primary/10 rounded px-2 py-1.5">
<span class="text-base-content font-medium">Position sur la machine</span> <span class="text-base-content font-medium">Emplacement</span>
<span class="font-bold">Secteur B - Ligne 3</span> <span class="font-bold">Haut (palier de tete)</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260506140000_FixComposantCascadeFKs extends AbstractMigration
{
public function getDescription(): string
{
return 'Add missing CASCADE FKs documents.composantid and machine_component_links.composantid; cleanup pre-existing orphan rows';
}
public function up(Schema $schema): void
{
// 1. Trace des suppressions à venir dans audit_logs (actor = NULL = "system").
// On copie un snapshot minimal avant DELETE pour cohérence avec les autres "delete".
$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),
'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');
}
}

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, protected 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,23 @@ 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,9 @@ 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;
}
} }

View File

@@ -7,6 +7,9 @@ namespace App\Tests\Api\Entity;
use App\Enum\ModelCategory; use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase; use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class MachineContextCustomFieldTest extends AbstractApiTestCase class MachineContextCustomFieldTest extends AbstractApiTestCase
{ {
public function testStructureReturnsContextFieldsOnComponentLink(): void public function testStructureReturnsContextFieldsOnComponentLink(): void
@@ -56,7 +59,7 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
$normalFields = array_filter( $normalFields = array_filter(
$componentLink['composant']['customFields'], $componentLink['composant']['customFields'],
fn (array $f) => $f['name'] === 'Serial', fn (array $f) => 'Serial' === $f['name'],
); );
$this->assertCount(1, $normalFields); $this->assertCount(1, $normalFields);
} }
@@ -65,8 +68,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
{ {
$client = $this->createGestionnaireClient(); $client = $this->createGestionnaireClient();
$site = $this->createSite('Site B'); $site = $this->createSite('Site B');
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE); $modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
$contextField = $this->createCustomField( $contextField = $this->createCustomField(
name: 'Wear Level', name: 'Wear Level',
type: 'select', type: 'select',
@@ -101,8 +104,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
{ {
$client = $this->createGestionnaireClient(); $client = $this->createGestionnaireClient();
$site = $this->createSite('Site C'); $site = $this->createSite('Site C');
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT); $modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
$contextField = $this->createCustomField( $contextField = $this->createCustomField(
name: 'Flow Rate', name: 'Flow Rate',
type: 'number', type: 'number',
@@ -131,8 +134,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
{ {
$client = $this->createGestionnaireClient(); $client = $this->createGestionnaireClient();
$site = $this->createSite('Site D'); $site = $this->createSite('Site D');
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT); $modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
$contextField = $this->createCustomField( $contextField = $this->createCustomField(
name: 'Pressure', name: 'Pressure',
type: 'number', type: 'number',
@@ -171,8 +174,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
{ {
$client = $this->createGestionnaireClient(); $client = $this->createGestionnaireClient();
$site = $this->createSite('Site E'); $site = $this->createSite('Site E');
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT); $modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
$contextField = $this->createCustomField( $contextField = $this->createCustomField(
name: 'Calibration Date', name: 'Calibration Date',
type: 'date', type: 'date',
@@ -190,8 +193,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
{ {
$client = $this->createGestionnaireClient(); $client = $this->createGestionnaireClient();
$site = $this->createSite('Site F'); $site = $this->createSite('Site F');
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT); $modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
$contextField = $this->createCustomField( $contextField = $this->createCustomField(
name: 'RPM Setting', name: 'RPM Setting',
type: 'number', type: 'number',
@@ -225,4 +228,84 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
$this->assertCount(1, $clonedLink['contextCustomFieldValues']); $this->assertCount(1, $clonedLink['contextCustomFieldValues']);
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']); $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']);
}
} }