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>
287 lines
11 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|