Compare commits

...

7 Commits

Author SHA1 Message Date
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
gitea-actions
c88333b052 chore : bump version to v1.9.29
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m6s
2026-05-03 18:05:16 +00:00
8f5cd98b82 fix(machine-clone) : preserve context field values when cloning a machine
All checks were successful
Auto Tag Develop / tag (push) Successful in 35s
Context CustomFieldValues attached to component/piece links were
silently dropped from the clone response (and from any subsequent
read in the same request) because the controller persisted the new
CFVs without adding them to the inverse-side collection of the new
link. Doctrine does not auto-sync inverse OneToMany associations,
so getContextFieldValues() returned an empty collection on the
freshly persisted link.

Also synchronise the inverse collection in the test factory so
identity-mapped entities reflect newly-created CFVs when reused
by request handlers within the same test.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:59:03 +02:00
48f7e4c6ac test(session) : align expectations with hardened auth from WIP 476060c
Generic 'Identifiants invalides.' is now returned for both wrong
password and missing-password-set cases (security obscurity, prevents
account enumeration). Tests still asserted the granular 'Mot de passe
incorrect.' message and a 403 status that the controller no longer
emits.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:56:53 +02:00
c46769a67d fix(model-types) : nullify weak references on ModelType delete
Belt-and-suspenders against orphan refs when a ModelType is deleted:
applicatively nullifies typeComposantId / typePieceId / typeProductId
on every "ON DELETE SET NULL" relationship before the row is removed,
in case the database FK cascade fails to fire.

Observed in prod 2026-04-28: deletion of ModelType "Paliers" left an
orphan in skeleton_subcomponent_requirements, surfacing as a 500 when
API Platform tried to lazy-load the missing proxy.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:29:36 +02:00
14 changed files with 276 additions and 211 deletions

View File

@@ -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.

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '1.9.28'
app.version: '1.9.29'

View File

@@ -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()
})
</script>

View File

@@ -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<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
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,

View File

@@ -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,45 +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);
}
}
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);
}
}
}
private function normalizePayloadList(mixed $value): array
{
if (!is_array($value)) {

View File

@@ -11,8 +11,8 @@ use App\Entity\Machine;
use App\Entity\ModelType;
use App\Entity\Piece;
use App\Entity\Product;
use App\Entity\Profile;
use App\Entity\Site;
use App\Service\ActorProfileResolver;
use BackedEnum;
use DateTimeInterface;
use Doctrine\Common\Collections\Collection;
@@ -22,10 +22,6 @@ use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\UnitOfWork;
use Error;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array;
use function is_object;
@@ -35,8 +31,7 @@ use function method_exists;
abstract class AbstractAuditSubscriber implements EventSubscriber
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
protected readonly ActorProfileResolver $actorProfileResolver,
) {}
public function getSubscribedEvents(): array
@@ -61,7 +56,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
}
}
$actorProfileId = $this->resolveActorProfileId();
$actorProfileId = $this->actorProfileResolver->resolve();
$entityType = $this->entityType();
if ($this->hasCollectionTracking()) {
@@ -278,28 +273,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
return $entity->getVersion();
}
protected function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
{
foreach ($uow->getScheduledEntityInsertions() as $entity) {
@@ -385,13 +358,10 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
}
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
// Note: scheduled collection updates/deletions are intentionally not
// tracked here — constructeurs are now persisted as ConstructeurLink
// entities (OneToMany), so Doctrine no longer fires collection events
// for them. Custom field values are handled below.
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);
foreach ($pendingUpdates as $entityId => $diff) {
@@ -411,17 +381,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
}
}
/**
* No-op: constructeurs are now tracked as ConstructeurLink entities (OneToMany),
* so Doctrine no longer fires collection update events for them.
*/
private function collectCollectionUpdate(
object $collection,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingEntities,
): void {}
private function collectCustomFieldValueChanges(
UnitOfWork $uow,
array &$pendingUpdates,

View File

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

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\ModelType;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use Doctrine\ORM\Events;
use function sprintf;
/**
* Belt-and-suspenders cleanup of weak references to a ModelType before deletion:
* runs the equivalent of every "ON DELETE SET NULL" cascade applicatively, in case
* the database FK fails to fire (observed on prod in 2026-04 — the deletion of
* ModelType "Paliers" left an orphan in skeleton_subcomponent_requirements).
*/
#[AsDoctrineListener(event: Events::preRemove)]
final class ModelTypeReferenceCleanupSubscriber
{
/** @var list<array{0: string, 1: string}> */
private const NULLABLE_REFERENCES = [
['skeleton_subcomponent_requirements', 'typecomposantid'],
['skeleton_piece_requirements', 'typepieceid'],
['skeleton_product_requirements', 'typeproductid'],
['composant_piece_slots', 'typepieceid'],
['composant_product_slots', 'typeproductid'],
['composant_subcomponent_slots', 'typecomposantid'],
['piece_product_slots', 'typeproductid'],
['machine_component_links', 'modeltypeid'],
['machine_piece_links', 'modeltypeid'],
['machine_product_links', 'modeltypeid'],
];
public function preRemove(PreRemoveEventArgs $args): void
{
$entity = $args->getObject();
if (!$entity instanceof ModelType) {
return;
}
$id = $entity->getId();
if (!$id) {
return;
}
$conn = $args->getObjectManager()->getConnection();
foreach (self::NULLABLE_REFERENCES as [$table, $column]) {
$conn->executeStatement(
sprintf('UPDATE %s SET %s = NULL WHERE %s = ?', $table, $column, $column),
[$id],
);
}
}
}

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

View File

@@ -4,23 +4,17 @@ declare(strict_types=1);
namespace App\Service;
use App\Entity\Profile;
use App\Enum\ModelCategory;
use App\Repository\ModelTypeRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
final class ModelTypeCategoryConversionService
{
public function __construct(
private readonly Connection $connection,
private readonly ModelTypeRepository $modelTypes,
private readonly RequestStack $requestStack,
private readonly Security $security,
private readonly ActorProfileResolver $actorProfileResolver,
) {}
/**
@@ -327,17 +321,7 @@ final class ModelTypeCategoryConversionService
);
// 7. Update ModelType
$this->connection->executeStatement(
'UPDATE model_types
SET category = :cat,
updatedat = :now
WHERE id = :id',
[
'cat' => ModelCategory::COMPONENT->value,
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
'id' => $modelTypeId,
],
);
$this->updateModelTypeCategory($modelTypeId, ModelCategory::COMPONENT);
return $count;
}
@@ -406,19 +390,24 @@ final class ModelTypeCategoryConversionService
);
// 7. Update ModelType
$this->updateModelTypeCategory($modelTypeId, ModelCategory::PIECE);
return $count;
}
private function updateModelTypeCategory(string $modelTypeId, ModelCategory $category): void
{
$this->connection->executeStatement(
'UPDATE model_types
SET category = :cat,
updatedat = :now
WHERE id = :id',
[
'cat' => ModelCategory::PIECE->value,
'cat' => $category->value,
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
'id' => $modelTypeId,
],
);
return $count;
}
/**
@@ -457,30 +446,9 @@ final class ModelTypeCategoryConversionService
'action' => 'convert',
'diff' => json_encode($diff),
'snapshot' => json_encode($snapshot),
'actor' => $this->resolveActorProfileId(),
'actor' => $this->actorProfileResolver->resolve(),
'now' => $now,
],
);
}
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

@@ -7,10 +7,10 @@ namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Entity\Composant;
use App\Entity\ComposantConstructeurLink;
use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\ComposantConstructeurLink;
use App\Entity\Constructeur;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
@@ -467,6 +467,14 @@ abstract class AbstractApiTestCase extends ApiTestCase
$em->persist($cfv);
$em->flush();
// Keep inverse-side collections in sync so identity-mapped entities reflect the new CFV.
if (null !== $machineComponentLink && !$machineComponentLink->getContextFieldValues()->contains($cfv)) {
$machineComponentLink->getContextFieldValues()->add($cfv);
}
if (null !== $machinePieceLink && !$machinePieceLink->getContextFieldValues()->contains($cfv)) {
$machinePieceLink->getContextFieldValues()->add($cfv);
}
return $cfv;
}

View File

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

View File

@@ -47,7 +47,7 @@ class SessionProfileTest extends AbstractApiTestCase
]);
$this->assertResponseStatusCodeSame(401);
$this->assertJsonContains(['message' => 'Mot de passe incorrect.']);
$this->assertJsonContains(['message' => 'Identifiants invalides.']);
}
public function testLoginMissingPassword(): void
@@ -103,7 +103,7 @@ class SessionProfileTest extends AbstractApiTestCase
],
]);
$this->assertResponseStatusCodeSame(403);
$this->assertResponseStatusCodeSame(401);
}
public function testGetActiveProfileAuthenticated(): void