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

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