fix(data-integrity) : prevent data loss in clone, slots, conversion and custom fields

- Clone: CustomFieldValue now references cloned CustomField, not source
- Slots: validate piece type matches slot requirement + 404 on missing piece
- Conversion: check slot tables before allowing category conversion + clean orphan skeleton requirements
- CustomFieldValue: prevent creation of orphan CustomField without target entity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-23 17:15:05 +01:00
parent d5a43fc9bb
commit 043f6b1ce6
4 changed files with 81 additions and 13 deletions

View File

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

View File

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

View File

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

View File

@@ -235,6 +235,45 @@ final class ModelTypeCategoryConversionService
}
}
// 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],
);
$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],
);
$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);
}
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
$collisions = $this->connection->fetchFirstColumn(
'SELECT c.name FROM composants c
@@ -383,12 +422,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