Files
Inventory/src/Service/SkeletonStructureService.php
Matthieu 38777b7de0 fix(custom-fields) : prevent data loss on ModelType save + restoration scripts
Backend: match existing CustomField by name as fallback when ID is not provided,
preventing deletion and recreation of field definitions (which cascade-deletes values).

Includes restoration/migration scripts for prod:
- restore-custom-field-values.php: restores piece values from audit logs
- migrate-orphaned-custom-fields.php: migrates values from orphaned CFs
- fix-prod-all.php: combined fix (migrate + restore + cleanup)
- fix-prod-recreate-and-migrate.php: full fix (recreate missing CFs + migrate + restore)
- check-prod-*.php: diagnostic scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:24:37 +01:00

287 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\CustomField;
use App\Entity\ModelType;
use App\Entity\SkeletonPieceRequirement;
use App\Entity\SkeletonProductRequirement;
use App\Entity\SkeletonSubcomponentRequirement;
use App\Enum\ModelCategory;
use Doctrine\ORM\EntityManagerInterface;
class SkeletonStructureService
{
public function __construct(private EntityManagerInterface $em) {}
public function updateSkeletonRequirements(ModelType $modelType, array $structure): void
{
// Update piece requirements in-place (match by typeId, then update position)
$this->syncPieceRequirements($modelType, $structure['pieces'] ?? []);
// Update product requirements in-place
$this->syncProductRequirements($modelType, $structure['products'] ?? []);
// Update subcomponent requirements in-place
$this->syncSubcomponentRequirements($modelType, $structure['subcomponents'] ?? []);
// Update custom field definitions
$this->updateCustomFields($modelType, $structure['customFields'] ?? []);
}
/**
* @param array<int, array{typePieceId: string}> $proposedPieces
*/
private function syncPieceRequirements(ModelType $modelType, array $proposedPieces): void
{
$existing = $modelType->getSkeletonPieceRequirements()->toArray();
// Index existing by typeId for matching
$existingByTypeId = [];
foreach ($existing as $req) {
$existingByTypeId[$req->getTypePiece()->getId()][] = $req;
}
$matched = [];
$toCreate = [];
foreach ($proposedPieces as $i => $pieceData) {
$typeId = $pieceData['typePieceId'];
if (!empty($existingByTypeId[$typeId])) {
// Reuse existing requirement, update position
$req = array_shift($existingByTypeId[$typeId]);
$req->setPosition($i);
$matched[spl_object_id($req)] = true;
} else {
$toCreate[] = ['data' => $pieceData, 'position' => $i];
}
}
// Remove unmatched existing requirements
foreach ($existing as $req) {
if (!isset($matched[spl_object_id($req)])) {
$modelType->removeSkeletonPieceRequirement($req);
}
}
// Create new requirements
foreach ($toCreate as $item) {
$req = new SkeletonPieceRequirement();
$req->setModelType($modelType);
$req->setTypePiece($this->em->getReference(ModelType::class, $item['data']['typePieceId']));
$req->setPosition($item['position']);
$modelType->addSkeletonPieceRequirement($req);
}
}
/**
* @param array<int, array{typeProductId: string, familyCode?: ?string}> $proposedProducts
*/
private function syncProductRequirements(ModelType $modelType, array $proposedProducts): void
{
$existing = $modelType->getSkeletonProductRequirements()->toArray();
$existingByTypeId = [];
foreach ($existing as $req) {
$existingByTypeId[$req->getTypeProduct()->getId()][] = $req;
}
$matched = [];
$toCreate = [];
foreach ($proposedProducts as $i => $prodData) {
$typeId = $prodData['typeProductId'];
if (!empty($existingByTypeId[$typeId])) {
$req = array_shift($existingByTypeId[$typeId]);
$req->setFamilyCode($prodData['familyCode'] ?? null);
$req->setPosition($i);
$matched[spl_object_id($req)] = true;
} else {
$toCreate[] = ['data' => $prodData, 'position' => $i];
}
}
foreach ($existing as $req) {
if (!isset($matched[spl_object_id($req)])) {
$modelType->removeSkeletonProductRequirement($req);
}
}
foreach ($toCreate as $item) {
$req = new SkeletonProductRequirement();
$req->setModelType($modelType);
$req->setTypeProduct($this->em->getReference(ModelType::class, $item['data']['typeProductId']));
$req->setFamilyCode($item['data']['familyCode'] ?? null);
$req->setPosition($item['position']);
$modelType->addSkeletonProductRequirement($req);
}
}
/**
* @param array<int, array{alias?: string, familyCode?: string, typeComposantId?: string}> $proposedSubs
*/
private function syncSubcomponentRequirements(ModelType $modelType, array $proposedSubs): void
{
$existing = $modelType->getSkeletonSubcomponentRequirements()->toArray();
$existingByTypeId = [];
foreach ($existing as $req) {
$key = $req->getTypeComposant()?->getId() ?? '';
$existingByTypeId[$key][] = $req;
}
$matched = [];
$toCreate = [];
foreach ($proposedSubs as $i => $subData) {
$typeId = $subData['typeComposantId'] ?? '';
if (!empty($existingByTypeId[$typeId])) {
$req = array_shift($existingByTypeId[$typeId]);
$req->setAlias($subData['alias'] ?? '');
$req->setFamilyCode($subData['familyCode'] ?? '');
if (!empty($subData['typeComposantId'])) {
$req->setTypeComposant($this->em->getReference(ModelType::class, $subData['typeComposantId']));
}
$req->setPosition($i);
$matched[spl_object_id($req)] = true;
} else {
$toCreate[] = ['data' => $subData, 'position' => $i];
}
}
foreach ($existing as $req) {
if (!isset($matched[spl_object_id($req)])) {
$modelType->removeSkeletonSubcomponentRequirement($req);
}
}
foreach ($toCreate as $item) {
$req = new SkeletonSubcomponentRequirement();
$req->setModelType($modelType);
$req->setAlias($item['data']['alias'] ?? '');
$req->setFamilyCode($item['data']['familyCode'] ?? '');
if (!empty($item['data']['typeComposantId'])) {
$req->setTypeComposant($this->em->getReference(ModelType::class, $item['data']['typeComposantId']));
}
$req->setPosition($item['position']);
$modelType->addSkeletonSubcomponentRequirement($req);
}
}
/**
* Sync CustomField entities for this ModelType.
* Handles two frontend formats:
* - COMPONENT: {key, value: {type, required, options?, defaultValue?}, id?, customFieldId?}
* - PIECE/PRODUCT: {name, type, required, options?, orderIndex?, defaultValue?}.
*/
private function updateCustomFields(ModelType $modelType, array $proposedFields): void
{
// Determine which FK to use based on category
$category = $modelType->getCategory();
$fkField = match ($category) {
ModelCategory::COMPONENT => 'typeComposant',
ModelCategory::PIECE => 'typePiece',
ModelCategory::PRODUCT => 'typeProduct',
};
// Load existing custom fields
$existingFields = $this->em->getRepository(CustomField::class)->findBy(
[$fkField => $modelType],
['orderIndex' => 'ASC']
);
// Index existing by ID and by name for matching
$existingById = [];
$existingByName = [];
foreach ($existingFields as $cf) {
$existingById[$cf->getId()] = $cf;
$existingByName[$cf->getName()] = $cf;
}
$processedIds = [];
foreach ($proposedFields as $i => $fieldData) {
// Normalize both formats to a common shape
$normalized = $this->normalizeCustomFieldData($fieldData, $i);
// Try to match an existing field by ID first, then by name as fallback
$existingField = null;
$fieldId = $fieldData['customFieldId'] ?? $fieldData['id'] ?? null;
if ($fieldId && isset($existingById[$fieldId])) {
$existingField = $existingById[$fieldId];
} elseif (isset($existingByName[$normalized['name']]) && !isset($processedIds[$existingByName[$normalized['name']]->getId()])) {
$existingField = $existingByName[$normalized['name']];
}
if ($existingField) {
// Update existing field
$existingField->setName($normalized['name']);
$existingField->setType($normalized['type']);
$existingField->setRequired($normalized['required']);
$existingField->setOptions($normalized['options']);
$existingField->setDefaultValue($normalized['defaultValue']);
$existingField->setOrderIndex($normalized['orderIndex']);
$processedIds[$existingField->getId()] = true;
} else {
// Create new field
$cf = new CustomField();
$cf->setName($normalized['name']);
$cf->setType($normalized['type']);
$cf->setRequired($normalized['required']);
$cf->setOptions($normalized['options']);
$cf->setDefaultValue($normalized['defaultValue']);
$cf->setOrderIndex($normalized['orderIndex']);
match ($category) {
ModelCategory::COMPONENT => $cf->setTypeComposant($modelType),
ModelCategory::PIECE => $cf->setTypePiece($modelType),
ModelCategory::PRODUCT => $cf->setTypeProduct($modelType),
};
$this->em->persist($cf);
}
}
// Remove orphaned fields (exist in DB but not in proposed)
foreach ($existingFields as $cf) {
if (!isset($processedIds[$cf->getId()])) {
$this->em->remove($cf);
}
}
}
/**
* Normalize frontend custom field data to a common shape.
*
* @return array{name: string, type: string, required: bool, options: ?array, defaultValue: ?string, orderIndex: int}
*/
private function normalizeCustomFieldData(array $fieldData, int $index): array
{
// COMPONENT format: {key: "name", value: {type, required, options?, defaultValue?}}
if (isset($fieldData['key'], $fieldData['value'])) {
$value = $fieldData['value'];
return [
'name' => $fieldData['key'],
'type' => $value['type'] ?? 'text',
'required' => (bool) ($value['required'] ?? false),
'options' => $value['options'] ?? null,
'defaultValue' => $value['defaultValue'] ?? null,
'orderIndex' => $index,
];
}
// PIECE/PRODUCT format: {name, type, required, options?, orderIndex?, defaultValue?}
return [
'name' => $fieldData['name'] ?? '',
'type' => $fieldData['type'] ?? 'text',
'required' => (bool) ($fieldData['required'] ?? false),
'options' => $fieldData['options'] ?? null,
'defaultValue' => $fieldData['defaultValue'] ?? null,
'orderIndex' => $fieldData['orderIndex'] ?? $index,
];
}
}