feat(categories) : add bidirectional piece/component category conversion
Backend service and controller for converting piece categories to component categories (and vice-versa). Uses raw SQL in a transaction to preserve IDs and transfer all related data (documents, custom fields, constructeurs). Includes php-cs-fixer formatting pass on existing controllers/entities. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
418
src/Service/ModelTypeCategoryConversionService.php
Normal file
418
src/Service/ModelTypeCategoryConversionService.php
Normal file
@@ -0,0 +1,418 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Repository\ModelTypeRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Throwable;
|
||||
|
||||
final class ModelTypeCategoryConversionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly ModelTypeRepository $modelTypes,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{canConvert: bool, direction: null|string, itemCount: int, names: list<string>, blockers: list<string>}
|
||||
*/
|
||||
public function checkConversion(string $modelTypeId): array
|
||||
{
|
||||
$modelType = $this->modelTypes->find($modelTypeId);
|
||||
|
||||
if (!$modelType) {
|
||||
return [
|
||||
'canConvert' => false,
|
||||
'direction' => null,
|
||||
'itemCount' => 0,
|
||||
'names' => [],
|
||||
'blockers' => ['Catégorie introuvable.'],
|
||||
];
|
||||
}
|
||||
|
||||
$category = $modelType->getCategory();
|
||||
|
||||
if (ModelCategory::PRODUCT === $category) {
|
||||
return [
|
||||
'canConvert' => false,
|
||||
'direction' => null,
|
||||
'itemCount' => 0,
|
||||
'names' => [],
|
||||
'blockers' => ['La conversion n\'est pas disponible pour les catégories de produit.'],
|
||||
];
|
||||
}
|
||||
|
||||
if (ModelCategory::PIECE === $category) {
|
||||
return $this->checkPieceToComponent($modelTypeId, $modelType->getName());
|
||||
}
|
||||
|
||||
return $this->checkComponentToPiece($modelTypeId, $modelType->getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success: bool, convertedCount: int, error: null|string}
|
||||
*/
|
||||
public function convert(string $modelTypeId): array
|
||||
{
|
||||
$check = $this->checkConversion($modelTypeId);
|
||||
|
||||
if (!$check['canConvert']) {
|
||||
return [
|
||||
'success' => false,
|
||||
'convertedCount' => 0,
|
||||
'error' => implode(' ', $check['blockers']),
|
||||
];
|
||||
}
|
||||
|
||||
$modelType = $this->modelTypes->find($modelTypeId);
|
||||
|
||||
if (!$modelType) {
|
||||
return ['success' => false, 'convertedCount' => 0, 'error' => 'Catégorie introuvable.'];
|
||||
}
|
||||
|
||||
$category = $modelType->getCategory();
|
||||
|
||||
$this->connection->beginTransaction();
|
||||
|
||||
try {
|
||||
if (ModelCategory::PIECE === $category) {
|
||||
$count = $this->convertPieceToComponent($modelTypeId);
|
||||
} else {
|
||||
$count = $this->convertComponentToPiece($modelTypeId);
|
||||
}
|
||||
|
||||
$this->connection->commit();
|
||||
|
||||
return ['success' => true, 'convertedCount' => $count, 'error' => null];
|
||||
} catch (Throwable $e) {
|
||||
$this->connection->rollBack();
|
||||
|
||||
return ['success' => false, 'convertedCount' => 0, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{canConvert: bool, direction: string, itemCount: int, names: list<string>, blockers: list<string>}
|
||||
*/
|
||||
private function checkPieceToComponent(string $modelTypeId, string $modelTypeName): array
|
||||
{
|
||||
$blockers = [];
|
||||
|
||||
$pieceCount = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM pieces WHERE typepieceid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
$names = $this->connection->fetchFirstColumn(
|
||||
'SELECT name FROM pieces WHERE typepieceid = :id ORDER BY name',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// Check machine links
|
||||
$machineLinked = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM machine_piece_links mpl
|
||||
JOIN pieces p ON mpl.pieceid = p.id
|
||||
WHERE p.typepieceid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
if ($machineLinked > 0) {
|
||||
$blockers[] = sprintf('%d pièce(s) liée(s) à des machines.', $machineLinked);
|
||||
}
|
||||
|
||||
// Check type machine requirements
|
||||
$requirementCount = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM type_machine_piece_requirements WHERE typepieceid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
if ($requirementCount > 0) {
|
||||
$blockers[] = sprintf('Utilisé dans %d modèle(s) de type de machine.', $requirementCount);
|
||||
}
|
||||
|
||||
// Check name collision with existing composants
|
||||
$collisions = $this->connection->fetchFirstColumn(
|
||||
'SELECT p.name FROM pieces p
|
||||
WHERE p.typepieceid = :id
|
||||
AND p.name IN (SELECT c.name FROM composants c)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
if ([] !== $collisions) {
|
||||
$blockers[] = sprintf(
|
||||
'Collision de nom avec des composants existants : %s.',
|
||||
implode(', ', $collisions),
|
||||
);
|
||||
}
|
||||
|
||||
// Check ModelType name collision
|
||||
$nameCollision = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM model_types WHERE category = :cat AND name = :name AND id != :id',
|
||||
['cat' => ModelCategory::COMPONENT->value, 'name' => $modelTypeName, 'id' => $modelTypeId],
|
||||
);
|
||||
|
||||
if ($nameCollision > 0) {
|
||||
$blockers[] = sprintf('Une catégorie de composant « %s » existe déjà.', $modelTypeName);
|
||||
}
|
||||
|
||||
return [
|
||||
'canConvert' => [] === $blockers,
|
||||
'direction' => 'piece_to_component',
|
||||
'itemCount' => $pieceCount,
|
||||
'names' => $names,
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{canConvert: bool, direction: string, itemCount: int, names: list<string>, blockers: list<string>}
|
||||
*/
|
||||
private function checkComponentToPiece(string $modelTypeId, string $modelTypeName): array
|
||||
{
|
||||
$blockers = [];
|
||||
|
||||
$composantCount = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM composants WHERE typecomposantid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
$names = $this->connection->fetchFirstColumn(
|
||||
'SELECT name FROM composants WHERE typecomposantid = :id ORDER BY name',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// Check machine links
|
||||
$machineLinked = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM machine_component_links mcl
|
||||
JOIN composants c ON mcl.composantid = c.id
|
||||
WHERE c.typecomposantid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
if ($machineLinked > 0) {
|
||||
$blockers[] = sprintf('%d composant(s) lié(s) à des machines.', $machineLinked);
|
||||
}
|
||||
|
||||
// Check type machine requirements
|
||||
$requirementCount = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM type_machine_component_requirements WHERE typecomposantid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
if ($requirementCount > 0) {
|
||||
$blockers[] = sprintf('Utilisé dans %d modèle(s) de type de machine.', $requirementCount);
|
||||
}
|
||||
|
||||
// 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',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
foreach ($withStructure as $row) {
|
||||
$structure = json_decode($row['structure'], true);
|
||||
|
||||
if (!is_array($structure)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check name collision with existing pieces
|
||||
$collisions = $this->connection->fetchFirstColumn(
|
||||
'SELECT c.name FROM composants c
|
||||
WHERE c.typecomposantid = :id
|
||||
AND c.name IN (SELECT p.name FROM pieces p)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
if ([] !== $collisions) {
|
||||
$blockers[] = sprintf(
|
||||
'Collision de nom avec des pièces existantes : %s.',
|
||||
implode(', ', $collisions),
|
||||
);
|
||||
}
|
||||
|
||||
// Check ModelType name collision
|
||||
$nameCollision = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM model_types WHERE category = :cat AND name = :name AND id != :id',
|
||||
['cat' => ModelCategory::PIECE->value, 'name' => $modelTypeName, 'id' => $modelTypeId],
|
||||
);
|
||||
|
||||
if ($nameCollision > 0) {
|
||||
$blockers[] = sprintf('Une catégorie de pièce « %s » existe déjà.', $modelTypeName);
|
||||
}
|
||||
|
||||
return [
|
||||
'canConvert' => [] === $blockers,
|
||||
'direction' => 'component_to_piece',
|
||||
'itemCount' => $composantCount,
|
||||
'names' => $names,
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
}
|
||||
|
||||
private function convertPieceToComponent(string $modelTypeId): int
|
||||
{
|
||||
// 1. Insert into composants from pieces
|
||||
$count = $this->connection->executeStatement(
|
||||
'INSERT INTO composants (id, name, reference, prix, structure, typecomposantid, productid, createdat, updatedat)
|
||||
SELECT id, name, reference, prix, NULL, typepieceid, productid, createdat, updatedat
|
||||
FROM pieces
|
||||
WHERE typepieceid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 2. Transfer constructeur associations
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO _composantconstructeurs (a, b)
|
||||
SELECT pc.a, pc.b FROM _piececonstructeurs pc
|
||||
WHERE pc.a IN (SELECT id FROM composants WHERE typecomposantid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM _piececonstructeurs
|
||||
WHERE a IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 3. Transfer document references
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE documents SET composantid = pieceid, pieceid = NULL
|
||||
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 4. Transfer custom_field_values references
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE custom_field_values SET composantid = pieceid, pieceid = NULL
|
||||
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 5. Transfer custom_fields from typePiece to typeComposant
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE custom_fields SET typecomposantid = typepieceid, typepieceid = NULL
|
||||
WHERE typepieceid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 6. Delete original pieces
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM pieces WHERE typepieceid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 7. Update ModelType
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE model_types
|
||||
SET category = :cat,
|
||||
componentskeleton = pieceskeleton,
|
||||
pieceskeleton = NULL,
|
||||
updatedat = :now
|
||||
WHERE id = :id',
|
||||
[
|
||||
'cat' => ModelCategory::COMPONENT->value,
|
||||
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
||||
'id' => $modelTypeId,
|
||||
],
|
||||
);
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function convertComponentToPiece(string $modelTypeId): int
|
||||
{
|
||||
// 1. Insert into pieces from composants
|
||||
$count = $this->connection->executeStatement(
|
||||
'INSERT INTO pieces (id, name, reference, prix, productids, typepieceid, productid, createdat, updatedat)
|
||||
SELECT id, name, reference, prix, NULL, typecomposantid, productid, createdat, updatedat
|
||||
FROM composants
|
||||
WHERE typecomposantid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 2. Transfer constructeur associations
|
||||
$this->connection->executeStatement(
|
||||
'INSERT INTO _piececonstructeurs (a, b)
|
||||
SELECT cc.a, cc.b FROM _composantconstructeurs cc
|
||||
WHERE cc.a IN (SELECT id FROM pieces WHERE typepieceid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM _composantconstructeurs
|
||||
WHERE a IN (SELECT id FROM composants WHERE typecomposantid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 3. Transfer document references
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE documents SET pieceid = composantid, composantid = NULL
|
||||
WHERE composantid IN (SELECT id FROM composants WHERE typecomposantid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 4. Transfer custom_field_values references
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE custom_field_values SET pieceid = composantid, composantid = NULL
|
||||
WHERE composantid IN (SELECT id FROM composants WHERE typecomposantid = :id)',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 5. Transfer custom_fields from typeComposant to typePiece
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE custom_fields SET typepieceid = typecomposantid, typecomposantid = NULL
|
||||
WHERE typecomposantid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 6. Delete original composants
|
||||
$this->connection->executeStatement(
|
||||
'DELETE FROM composants WHERE typecomposantid = :id',
|
||||
['id' => $modelTypeId],
|
||||
);
|
||||
|
||||
// 7. Update ModelType
|
||||
$this->connection->executeStatement(
|
||||
'UPDATE model_types
|
||||
SET category = :cat,
|
||||
pieceskeleton = componentskeleton,
|
||||
componentskeleton = NULL,
|
||||
updatedat = :now
|
||||
WHERE id = :id',
|
||||
[
|
||||
'cat' => ModelCategory::PIECE->value,
|
||||
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
|
||||
'id' => $modelTypeId,
|
||||
],
|
||||
);
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user