Compare commits
2 Commits
d5a43fc9bb
...
509c4d2247
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
509c4d2247 | ||
|
|
043f6b1ce6 |
@@ -43,6 +43,18 @@ class ComposantPieceSlotController extends AbstractController
|
||||
$slot->setSelectedPiece(null);
|
||||
} else {
|
||||
$piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']);
|
||||
if (!$piece) {
|
||||
return $this->json(['success' => false, 'error' => 'Pièce introuvable.'], 404);
|
||||
}
|
||||
|
||||
$slotTypePiece = $slot->getTypePiece();
|
||||
if ($slotTypePiece && $piece->getTypePiece()?->getId() !== $slotTypePiece->getId()) {
|
||||
return $this->json([
|
||||
'success' => false,
|
||||
'error' => sprintf('La pièce doit être de type « %s ».', $slotTypePiece->getName()),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$slot->setSelectedPiece($piece);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,19 +196,16 @@ class CustomFieldValueController extends AbstractController
|
||||
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
|
||||
}
|
||||
|
||||
$customField = new CustomField();
|
||||
$customField->setName($customFieldName);
|
||||
$customField->setType((string) ($payload['customFieldType'] ?? 'text'));
|
||||
$customField->setRequired((bool) ($payload['customFieldRequired'] ?? false));
|
||||
|
||||
$options = $payload['customFieldOptions'] ?? null;
|
||||
if (is_array($options)) {
|
||||
$customField->setOptions($options);
|
||||
// Try to find an existing custom field by name instead of creating an orphan
|
||||
$existing = $this->customFieldRepository->findOneBy(['name' => $customFieldName]);
|
||||
if ($existing instanceof CustomField) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$this->entityManager->persist($customField);
|
||||
|
||||
return $customField;
|
||||
return $this->json([
|
||||
'success' => false,
|
||||
'error' => sprintf('Custom field "%s" not found. Create it explicitly first.', $customFieldName),
|
||||
], 404);
|
||||
}
|
||||
|
||||
private function resolveTarget(array $payload): array|JsonResponse
|
||||
|
||||
@@ -173,6 +173,8 @@ class MachineStructureController extends AbstractController
|
||||
|
||||
private function cloneCustomFields(Machine $source, Machine $target): void
|
||||
{
|
||||
$cfMap = [];
|
||||
|
||||
foreach ($source->getCustomFields() as $cf) {
|
||||
$newCf = new CustomField();
|
||||
$newCf->setName($cf->getName());
|
||||
@@ -183,12 +185,20 @@ class MachineStructureController extends AbstractController
|
||||
$newCf->setOrderIndex($cf->getOrderIndex());
|
||||
$newCf->setMachine($target);
|
||||
$this->entityManager->persist($newCf);
|
||||
|
||||
$cfMap[$cf->getId()] = $newCf;
|
||||
}
|
||||
|
||||
foreach ($source->getCustomFieldValues() as $cfv) {
|
||||
$originalCf = $cfv->getCustomField();
|
||||
$newCf = $cfMap[$originalCf->getId()] ?? null;
|
||||
if (!$newCf) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$newValue = new CustomFieldValue();
|
||||
$newValue->setMachine($target);
|
||||
$newValue->setCustomField($cfv->getCustomField());
|
||||
$newValue->setCustomField($newCf);
|
||||
$newValue->setValue($cfv->getValue());
|
||||
$this->entityManager->persist($newValue);
|
||||
}
|
||||
|
||||
@@ -200,39 +200,43 @@ final class ModelTypeCategoryConversionService
|
||||
$blockers[] = sprintf('%d composant(s) lié(s) à des machines.', $machineLinked);
|
||||
}
|
||||
|
||||
// Check if any composant has pieces or sub-components in structure
|
||||
$withStructure = $this->connection->fetchAllAssociative(
|
||||
'SELECT name, structure FROM composants WHERE typecomposantid = :id AND structure IS NOT NULL',
|
||||
// Check slot tables for actual data (post-normalization architecture)
|
||||
$filledPieceSlots = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM composant_piece_slots cps
|
||||
JOIN composants c ON cps.composantid = c.id
|
||||
WHERE c.typecomposantid = :id AND cps.selectedpieceid IS NOT NULL',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
foreach ($withStructure as $row) {
|
||||
$structure = json_decode($row['structure'], true);
|
||||
$filledSubSlots = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM composant_subcomponent_slots css
|
||||
JOIN composants c ON css.composantid = c.id
|
||||
WHERE c.typecomposantid = :id AND css.selectedcomposantid IS NOT NULL',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
if (!is_array($structure)) {
|
||||
continue;
|
||||
$filledProductSlots = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM composant_product_slots cps
|
||||
JOIN composants c ON cps.composantid = c.id
|
||||
WHERE c.typecomposantid = :id AND cps.selectedproductid IS NOT NULL',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
if ($filledPieceSlots > 0 || $filledSubSlots > 0 || $filledProductSlots > 0) {
|
||||
$parts = [];
|
||||
if ($filledPieceSlots > 0) {
|
||||
$parts[] = sprintf('%d slot(s) pièce', $filledPieceSlots);
|
||||
}
|
||||
|
||||
$hasPieces = !empty($structure['pieces']);
|
||||
$hasSubcomponents = !empty($structure['subcomponents']);
|
||||
|
||||
if ($hasPieces || $hasSubcomponents) {
|
||||
$parts = [];
|
||||
|
||||
if ($hasPieces) {
|
||||
$parts[] = 'pièces';
|
||||
}
|
||||
|
||||
if ($hasSubcomponents) {
|
||||
$parts[] = 'sous-composants';
|
||||
}
|
||||
|
||||
$blockers[] = sprintf(
|
||||
'Le composant « %s » contient des %s dans sa structure.',
|
||||
$row['name'],
|
||||
implode(' et ', $parts),
|
||||
);
|
||||
if ($filledSubSlots > 0) {
|
||||
$parts[] = sprintf('%d slot(s) sous-composant', $filledSubSlots);
|
||||
}
|
||||
if ($filledProductSlots > 0) {
|
||||
$parts[] = sprintf('%d slot(s) produit', $filledProductSlots);
|
||||
}
|
||||
$blockers[] = sprintf(
|
||||
'Des composants ont des données dans leurs slots : %s.',
|
||||
implode(', ', $parts),
|
||||
);
|
||||
}
|
||||
|
||||
// Check name collision with existing pieces
|
||||
@@ -383,12 +387,22 @@ final class ModelTypeCategoryConversionService
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 6. Delete original composants
|
||||
// 6. Delete original composants (cascades to slot tables)
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM composants WHERE typecomposantid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 6b. Clean up skeleton requirements that only apply to COMPONENT category
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM skeleton_piece_requirements WHERE modeltypeid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM skeleton_subcomponent_requirements WHERE modeltypeid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 7. Update ModelType
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE model_types
|
||||
|
||||
256
tests/Api/DataIntegrityTest.php
Normal file
256
tests/Api/DataIntegrityTest.php
Normal file
@@ -0,0 +1,256 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Api;
|
||||
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\CustomFieldValue;
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
/**
|
||||
* Data integrity tests — verifies fixes that prevent data loss
|
||||
* in clone, slots, conversion, and custom fields.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
class DataIntegrityTest extends AbstractApiTestCase
|
||||
{
|
||||
// -----------------------------------------------------------------------
|
||||
// T1 — Clone: CustomFieldValue must reference cloned CustomField
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public function testCloneCustomFieldValuesReferenceClonedDefinitions(): void
|
||||
{
|
||||
$source = $this->createMachine('Source Machine');
|
||||
$cf = $this->createCustomField('Puissance', 'text', $source);
|
||||
$this->createCustomFieldValue($cf, '15 kW', $source);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'json' => [
|
||||
'name' => 'Cloned Machine',
|
||||
'siteId' => $source->getSite()->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $client->getResponse()->toArray();
|
||||
|
||||
$clonedMachineId = $data['machine']['id'] ?? null;
|
||||
$this->assertNotNull($clonedMachineId, 'Clone should return new machine ID');
|
||||
$this->assertNotSame($source->getId(), $clonedMachineId);
|
||||
|
||||
// Verify cloned CustomFieldValues reference CustomFields owned by the clone
|
||||
$em = $this->getEntityManager();
|
||||
$clonedValues = $em->getRepository(CustomFieldValue::class)->findBy(['machine' => $clonedMachineId]);
|
||||
$clonedFields = $em->getRepository(CustomField::class)->findBy(['machine' => $clonedMachineId]);
|
||||
|
||||
$this->assertNotEmpty($clonedValues, 'Clone should have custom field values');
|
||||
$this->assertNotEmpty($clonedFields, 'Clone should have custom field definitions');
|
||||
|
||||
$clonedFieldIds = array_map(static fn (CustomField $cf) => $cf->getId(), $clonedFields);
|
||||
|
||||
foreach ($clonedValues as $cfv) {
|
||||
$this->assertContains(
|
||||
$cfv->getCustomField()->getId(),
|
||||
$clonedFieldIds,
|
||||
'Cloned CustomFieldValue must reference a CustomField owned by the cloned machine, not the source'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function testCloneCustomFieldValuesPreserveValues(): void
|
||||
{
|
||||
$source = $this->createMachine('Source');
|
||||
$cf1 = $this->createCustomField('Tension', 'text', $source);
|
||||
$cf2 = $this->createCustomField('Vitesse', 'text', $source);
|
||||
$this->createCustomFieldValue($cf1, '400 V', $source);
|
||||
$this->createCustomFieldValue($cf2, '1500 tr/min', $source);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'json' => [
|
||||
'name' => 'Clone with values',
|
||||
'siteId' => $source->getSite()->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $client->getResponse()->toArray();
|
||||
$clonedMachineId = $data['machine']['id'];
|
||||
|
||||
$em = $this->getEntityManager();
|
||||
$clonedValues = $em->getRepository(CustomFieldValue::class)->findBy(['machine' => $clonedMachineId]);
|
||||
|
||||
$valueMap = [];
|
||||
foreach ($clonedValues as $cfv) {
|
||||
$valueMap[$cfv->getCustomField()->getName()] = $cfv->getValue();
|
||||
}
|
||||
|
||||
$this->assertSame('400 V', $valueMap['Tension'] ?? null);
|
||||
$this->assertSame('1500 tr/min', $valueMap['Vitesse'] ?? null);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// T2 — Slot PATCH: type validation + 404 on missing piece
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public function testSlotPatchWithNonExistentPieceReturns404(): void
|
||||
{
|
||||
$composant = $this->createComposant('Comp slot 404');
|
||||
$pieceType = $this->createModelType('Roulement', 'ROUL-404', ModelCategory::PIECE);
|
||||
$slot = $this->createComposantPieceSlot($composant, $pieceType, null, 1, 0);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('PATCH', '/api/composant-piece-slots/'.$slot->getId(), [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'json' => ['selectedPieceId' => 'cl_nonexistent_id_123'],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testSlotPatchWithWrongPieceTypeReturns422(): void
|
||||
{
|
||||
$typeA = $this->createModelType('Roulement', 'ROUL-A', ModelCategory::PIECE);
|
||||
$typeB = $this->createModelType('Joint', 'JOINT-B', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('Comp slot type');
|
||||
$slot = $this->createComposantPieceSlot($composant, $typeA, null, 1, 0);
|
||||
$wrongPiece = $this->createPiece('Joint XY', 'REF-JXY', $typeB);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('PATCH', '/api/composant-piece-slots/'.$slot->getId(), [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'json' => ['selectedPieceId' => $wrongPiece->getId()],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$data = $client->getResponse()->toArray(false);
|
||||
$this->assertStringContainsString('Roulement', $data['error']);
|
||||
}
|
||||
|
||||
public function testSlotPatchWithCorrectPieceTypeSucceeds(): void
|
||||
{
|
||||
$type = $this->createModelType('Filtre', 'FILT-OK', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('Comp slot OK');
|
||||
$slot = $this->createComposantPieceSlot($composant, $type, null, 1, 0);
|
||||
$piece = $this->createPiece('Filtre Parker', 'REF-FP', $type);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('PATCH', '/api/composant-piece-slots/'.$slot->getId(), [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'json' => ['selectedPieceId' => $piece->getId()],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $client->getResponse()->toArray();
|
||||
$this->assertSame($piece->getId(), $data['selectedPieceId']);
|
||||
}
|
||||
|
||||
public function testSlotPatchClearPieceSucceeds(): void
|
||||
{
|
||||
$type = $this->createModelType('Joint', 'JOINT-CLR', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('Comp slot clear');
|
||||
$piece = $this->createPiece('Joint A', 'REF-JA', $type);
|
||||
$slot = $this->createComposantPieceSlot($composant, $type, $piece, 1, 0);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('PATCH', '/api/composant-piece-slots/'.$slot->getId(), [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'json' => ['selectedPieceId' => null],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $client->getResponse()->toArray();
|
||||
$this->assertNull($data['selectedPieceId']);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// T3 — Conversion: blocks when slots have data
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public function testConversionBlockedWhenSlotsHaveData(): void
|
||||
{
|
||||
$compType = $this->createModelType('Pompe', 'POMPE-CONV', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Joint', 'JOINT-CONV', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('Pompe A', null, $compType);
|
||||
$piece = $this->createPiece('Joint réel', 'REF-JR', $pieceType);
|
||||
$this->createComposantPieceSlot($composant, $pieceType, $piece, 1, 0);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('GET', '/api/model_types/'.$compType->getId().'/conversion-check');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $client->getResponse()->toArray();
|
||||
|
||||
$this->assertFalse($data['canConvert'], 'Conversion should be blocked when slots have data');
|
||||
$this->assertNotEmpty($data['blockers']);
|
||||
}
|
||||
|
||||
public function testConversionAllowedWhenSlotsEmpty(): void
|
||||
{
|
||||
$compType = $this->createModelType('Vanne', 'VANNE-CONV', ModelCategory::COMPONENT);
|
||||
$pieceType = $this->createModelType('Joint', 'JOINT-EMPTY', ModelCategory::PIECE);
|
||||
$composant = $this->createComposant('Vanne A', null, $compType);
|
||||
// Slot without selected piece (empty)
|
||||
$this->createComposantPieceSlot($composant, $pieceType, null, 1, 0);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('GET', '/api/model_types/'.$compType->getId().'/conversion-check');
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $client->getResponse()->toArray();
|
||||
|
||||
// May still be blocked by machine links, but NOT by slots
|
||||
$slotBlocker = array_filter(
|
||||
$data['blockers'] ?? [],
|
||||
static fn (string $b) => str_contains($b, 'slot')
|
||||
);
|
||||
$this->assertEmpty($slotBlocker, 'Empty slots should not block conversion');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// T4 — CustomFieldValue: no orphan CustomField creation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public function testCustomFieldValueRejectsOrphanCreation(): void
|
||||
{
|
||||
$machine = $this->createMachine('Machine CF');
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/custom-fields/values', [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'json' => [
|
||||
'customFieldName' => 'NonExistentField',
|
||||
'value' => 'some value',
|
||||
'entityType' => 'machine',
|
||||
'entityId' => $machine->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(404);
|
||||
}
|
||||
|
||||
public function testCustomFieldValueAcceptsExistingFieldById(): void
|
||||
{
|
||||
$machine = $this->createMachine('Machine CF ok');
|
||||
$cf = $this->createCustomField('Existing Field', 'text', $machine);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/custom-fields/values', [
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'json' => [
|
||||
'customFieldId' => $cf->getId(),
|
||||
'value' => 'test value',
|
||||
'entityType' => 'machine',
|
||||
'entityId' => $machine->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user