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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user