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);
|
$slot->setSelectedPiece(null);
|
||||||
} else {
|
} else {
|
||||||
$piece = $this->entityManager->find(Piece::class, $payload['selectedPieceId']);
|
$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);
|
$slot->setSelectedPiece($piece);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,19 +196,16 @@ class CustomFieldValueController extends AbstractController
|
|||||||
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
|
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$customField = new CustomField();
|
// Try to find an existing custom field by name instead of creating an orphan
|
||||||
$customField->setName($customFieldName);
|
$existing = $this->customFieldRepository->findOneBy(['name' => $customFieldName]);
|
||||||
$customField->setType((string) ($payload['customFieldType'] ?? 'text'));
|
if ($existing instanceof CustomField) {
|
||||||
$customField->setRequired((bool) ($payload['customFieldRequired'] ?? false));
|
return $existing;
|
||||||
|
|
||||||
$options = $payload['customFieldOptions'] ?? null;
|
|
||||||
if (is_array($options)) {
|
|
||||||
$customField->setOptions($options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->entityManager->persist($customField);
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
return $customField;
|
'error' => sprintf('Custom field "%s" not found. Create it explicitly first.', $customFieldName),
|
||||||
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveTarget(array $payload): array|JsonResponse
|
private function resolveTarget(array $payload): array|JsonResponse
|
||||||
|
|||||||
@@ -173,6 +173,8 @@ class MachineStructureController extends AbstractController
|
|||||||
|
|
||||||
private function cloneCustomFields(Machine $source, Machine $target): void
|
private function cloneCustomFields(Machine $source, Machine $target): void
|
||||||
{
|
{
|
||||||
|
$cfMap = [];
|
||||||
|
|
||||||
foreach ($source->getCustomFields() as $cf) {
|
foreach ($source->getCustomFields() as $cf) {
|
||||||
$newCf = new CustomField();
|
$newCf = new CustomField();
|
||||||
$newCf->setName($cf->getName());
|
$newCf->setName($cf->getName());
|
||||||
@@ -183,12 +185,20 @@ class MachineStructureController extends AbstractController
|
|||||||
$newCf->setOrderIndex($cf->getOrderIndex());
|
$newCf->setOrderIndex($cf->getOrderIndex());
|
||||||
$newCf->setMachine($target);
|
$newCf->setMachine($target);
|
||||||
$this->entityManager->persist($newCf);
|
$this->entityManager->persist($newCf);
|
||||||
|
|
||||||
|
$cfMap[$cf->getId()] = $newCf;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($source->getCustomFieldValues() as $cfv) {
|
foreach ($source->getCustomFieldValues() as $cfv) {
|
||||||
|
$originalCf = $cfv->getCustomField();
|
||||||
|
$newCf = $cfMap[$originalCf->getId()] ?? null;
|
||||||
|
if (!$newCf) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$newValue = new CustomFieldValue();
|
$newValue = new CustomFieldValue();
|
||||||
$newValue->setMachine($target);
|
$newValue->setMachine($target);
|
||||||
$newValue->setCustomField($cfv->getCustomField());
|
$newValue->setCustomField($newCf);
|
||||||
$newValue->setValue($cfv->getValue());
|
$newValue->setValue($cfv->getValue());
|
||||||
$this->entityManager->persist($newValue);
|
$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
|
// Check name collision with existing pieces
|
||||||
$collisions = $this->connection->fetchFirstColumn(
|
$collisions = $this->connection->fetchFirstColumn(
|
||||||
'SELECT c.name FROM composants c
|
'SELECT c.name FROM composants c
|
||||||
@@ -383,12 +422,22 @@ final class ModelTypeCategoryConversionService
|
|||||||
['id' => $modelTypeId],
|
['id' => $modelTypeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 6. Delete original composants
|
// 6. Delete original composants (cascades to slot tables)
|
||||||
$this->connection->executeStatement(
|
$this->connection->executeStatement(
|
||||||
'DELETE FROM composants WHERE typecomposantid = :id',
|
'DELETE FROM composants WHERE typecomposantid = :id',
|
||||||
['id' => $modelTypeId],
|
['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
|
// 7. Update ModelType
|
||||||
$this->connection->executeStatement(
|
$this->connection->executeStatement(
|
||||||
'UPDATE model_types
|
'UPDATE model_types
|
||||||
|
|||||||
Reference in New Issue
Block a user