15 KiB
Fix Data-Loss Bugs — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fix all bugs that cause silent data loss in the composant/piece/product/skeleton/custom-fields data model.
Architecture: 6 independent fixes across backend (PHP) and frontend (TS). Each task is self-contained and can be committed independently. Backend fixes come first because they protect data integrity at the source.
Tech Stack: Symfony 8 / PHP 8.4 / PostgreSQL 16 / Nuxt 4 / Vue 3 / TypeScript
File Map
| Task | Action | File |
|---|---|---|
| T1 | Modify | src/Controller/MachineStructureController.php:174-195 |
| T2 | Modify | src/Controller/ComposantPieceSlotController.php:41-47 |
| T3 | Modify | src/Service/ModelTypeCategoryConversionService.php:195-236 |
| T3 | Modify | src/Service/ModelTypeCategoryConversionService.php:340-405 |
| T4 | Modify | src/Controller/CustomFieldValueController.php:199-211 |
| T5 | Modify | frontend/app/composables/useComponentEdit.ts:398-405 |
| T5 | Modify | frontend/app/composables/usePieceEdit.ts:407-414 |
| T6 | Modify | frontend/app/composables/useComponentCreate.ts (same pattern if present) |
Task 1: Clone machine — CustomFieldValue pointe vers les CustomField de la source
Probleme: cloneCustomFields clone les CustomField (definitions) pour la target, mais les CustomFieldValue (valeurs) restent liees aux CustomField de la source. Supprimer la source cascade-delete les valeurs du clone.
Files:
-
Modify:
src/Controller/MachineStructureController.php:174-195 -
Test:
tests/Api/Controller/MachineStructureControllerTest.php(clone test existant) -
Step 1: Write the failing test
Dans le test de clone existant, ajouter une assertion : apres clone, verifier que chaque CustomFieldValue de la machine clonee pointe vers un CustomField dont machineId est l'ID de la machine clonee (pas la source).
// After clone, fetch the cloned machine's custom field values
$clonedValues = $em->getRepository(CustomFieldValue::class)->findBy(['machine' => $clonedMachine]);
foreach ($clonedValues as $cfv) {
$this->assertSame(
$clonedMachine->getId(),
$cfv->getCustomField()->getMachine()->getId(),
'Cloned CustomFieldValue must reference the cloned CustomField, not the source'
);
}
- Step 2: Run test to verify it fails
Run: make test FILES=tests/Api/Controller/MachineStructureControllerTest.php
Expected: FAIL — cloned values reference source machine's custom fields
- Step 3: Implement the fix
In cloneCustomFields, build a map $oldCfId => $newCf in the first loop, then use it in the second loop:
private function cloneCustomFields(Machine $source, Machine $target): void
{
$cfMap = [];
foreach ($source->getCustomFields() as $cf) {
$newCf = new CustomField();
$newCf->setName($cf->getName());
$newCf->setType($cf->getType());
$newCf->setRequired($cf->isRequired());
$newCf->setDefaultValue($cf->getDefaultValue());
$newCf->setOptions($cf->getOptions());
$newCf->setOrderIndex($cf->getOrderIndex());
$newCf->setMachine($target);
$this->entityManager->persist($newCf);
$cfMap[$cf->getId()] = $newCf;
}
foreach ($source->getCustomFieldValues() as $cfv) {
$originalCf = $cfv->getCustomField();
$newCf = $cfMap[$originalCf->getId()] ?? null;
if (!$newCf) {
continue;
}
$newValue = new CustomFieldValue();
$newValue->setMachine($target);
$newValue->setCustomField($newCf);
$newValue->setValue($cfv->getValue());
$this->entityManager->persist($newValue);
}
}
- Step 4: Run test to verify it passes
Run: make test FILES=tests/Api/Controller/MachineStructureControllerTest.php
Expected: PASS
- Step 5: Lint
Run: make php-cs-fixer-allow-risky
- Step 6: Commit
git add src/Controller/MachineStructureController.php tests/Api/Controller/MachineStructureControllerTest.php
git commit -m "fix(clone) : custom field values reference cloned definitions, not source"
Task 2: ComposantPieceSlot PATCH — pas de validation du type de piece ni 404
Probleme: On peut assigner n'importe quelle piece dans un slot, meme si son type ne correspond pas au type requis par le squelette. Si la piece n'existe pas, null est silencieusement mis.
Files:
-
Modify:
src/Controller/ComposantPieceSlotController.php:41-47 -
Test:
tests/Api/Controller/ComposantPieceSlotControllerTest.php(creer si absent) -
Step 1: Write the failing test — piece not found returns 404
public function testPatchSlotWithNonExistentPieceReturns404(): void
{
$client = $this->createGestionnaireClient();
// Create a slot via fixtures
$slot = $this->createComposantPieceSlot();
$client->request('PATCH', '/api/composant-piece-slots/' . $slot->getId(), [
'json' => ['selectedPieceId' => 'cl_nonexistent_id'],
'headers' => ['Content-Type' => 'application/json'],
]);
$this->assertResponseStatusCodeSame(404);
}
- Step 2: Write the failing test — wrong piece type returns 422
public function testPatchSlotWithWrongPieceTypeReturns422(): void
{
$client = $this->createGestionnaireClient();
$typeA = $this->createModelType(['category' => 'piece', 'name' => 'Type A']);
$typeB = $this->createModelType(['category' => 'piece', 'name' => 'Type B']);
$slot = $this->createComposantPieceSlot(['typePiece' => $typeA]);
$wrongPiece = $this->createPiece(['typePiece' => $typeB]);
$client->request('PATCH', '/api/composant-piece-slots/' . $slot->getId(), [
'json' => ['selectedPieceId' => $wrongPiece->getId()],
'headers' => ['Content-Type' => 'application/json'],
]);
$this->assertResponseStatusCodeSame(422);
}
- Step 3: Run tests to verify they fail
Run: make test FILES=tests/Api/Controller/ComposantPieceSlotControllerTest.php
Expected: FAIL
- Step 4: Implement the fix
if (array_key_exists('selectedPieceId', $payload)) {
if (null === $payload['selectedPieceId']) {
$slot->setSelectedPiece(null);
} else {
$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);
}
}
- Step 5: Run tests to verify they pass
Run: make test FILES=tests/Api/Controller/ComposantPieceSlotControllerTest.php
Expected: PASS
- Step 6: Lint + commit
make php-cs-fixer-allow-risky
git add src/Controller/ComposantPieceSlotController.php tests/Api/Controller/ComposantPieceSlotControllerTest.php
git commit -m "fix(slots) : validate piece type matches slot requirement + 404 on missing piece"
Task 3: Conversion de categorie — slots supprimes sans verification + skeleton requirements orphelins
Probleme A: checkComponentToPiece verifie structure IS NOT NULL (ancien JSON) mais les donnees sont dans les tables de slots. Le check passe toujours et les slots sont cascade-deleted.
Probleme B: Apres conversion, les skeleton_piece_requirements, skeleton_product_requirements, skeleton_subcomponent_requirements de l'ancien type ne sont pas supprimes.
Files:
-
Modify:
src/Service/ModelTypeCategoryConversionService.php:195-236(check) -
Modify:
src/Service/ModelTypeCategoryConversionService.php:340-405(convert) -
Step 1: Fix
checkComponentToPiece— ajouter le check sur les tables de slots
Apres le check structure IS NOT NULL existant (qui reste pour compatibilite), ajouter :
// Check slot tables for actual data (post-normalization architecture)
$slotsWithData = (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],
);
$subSlots = (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],
);
if ($slotsWithData > 0 || $subSlots > 0) {
$parts = [];
if ($slotsWithData > 0) {
$parts[] = sprintf('%d slot(s) pièce rempli(s)', $slotsWithData);
}
if ($subSlots > 0) {
$parts[] = sprintf('%d slot(s) sous-composant rempli(s)', $subSlots);
}
$blockers[] = sprintf(
'Des composants ont des données dans leurs slots : %s.',
implode(', ', $parts),
);
}
- Step 2: Fix
convertComponentToPiece— nettoyer les skeleton requirements avant le changement de categorie
Ajouter entre l'etape 6 (DELETE composants) et l'etape 7 (UPDATE model_types) :
// 6b. Clean up skeleton requirements that belong 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],
);
// Note: skeleton_product_requirements are kept — valid for both COMPONENT and PIECE categories
- Step 3: Fix
convertPieceToComponent— meme nettoyage dans l'autre sens
Les skeleton_product_requirements qui appartenaient au type PIECE restent. Aucun nettoyage specifique necessaire car les product requirements sont valides pour les deux types. Mais verifier que la methode existe et n'a pas le meme probleme.
- Step 4: Run all conversion tests
Run: make test FILES=tests/Api/Controller/ModelTypeConversionControllerTest.php
Si absent: make test (tous les tests)
Expected: PASS
- Step 5: Lint + commit
make php-cs-fixer-allow-risky
git add src/Service/ModelTypeCategoryConversionService.php
git commit -m "fix(conversion) : block conversion when slots have data + clean skeleton requirements"
Task 4: CustomFieldValueController — cree des CustomField orphelins sans FK
Probleme: Quand customFieldId est absent et customFieldName est fourni, un nouveau CustomField est cree sans etre rattache a aucune entite (ni machine, ni modelType). La ligne est invisible et inutile.
Files:
-
Modify:
src/Controller/CustomFieldValueController.php:199-211 -
Step 1: Implement the fix
La methode resolveCustomField cree un CustomField orphelin. Il faut utiliser le target (deja resolu) pour rattacher le champ au bon parent. Le plus simple : deplacer la creation du CustomField apres la resolution du target, ou passer le target en parametre.
Option retenue : retourner un array ['customField' => $cf, 'isNew' => true] et laisser applyTarget gerer le rattachement, OU plus simplement, interdire la creation ad-hoc et retourner une erreur 400 quand le champ n'existe pas.
L'approche la plus sure (pas de CustomField orphelin) :
// In resolveCustomField, replace the auto-creation block with:
$customFieldName = isset($payload['customFieldName']) ? trim((string) $payload['customFieldName']) : '';
if ('' === $customFieldName) {
return $this->json(['success' => false, 'error' => 'customFieldId or customFieldName is required.'], 400);
}
// Try to find existing custom field by name for the target entity
$target = $this->resolveTarget($payload);
if ($target instanceof JsonResponse) {
return $this->json(['success' => false, 'error' => 'Cannot create custom field without a valid target entity.'], 400);
}
$existingField = $this->customFieldRepository->findOneBy(['name' => $customFieldName]);
if ($existingField) {
return $existingField;
}
return $this->json(['success' => false, 'error' => sprintf('Custom field "%s" not found. Create it explicitly first.', $customFieldName)], 404);
Alternative plus conservative si le frontend depend de cette auto-creation : garder la creation mais rattacher au target. Cela necessite de refactorer le flow pour passer le target a resolveCustomField. Choisir selon le frontend.
- Step 2: Run tests
Run: make test
Expected: PASS (verifier qu'aucun test ne depend de l'auto-creation)
- Step 3: Lint + commit
make php-cs-fixer-allow-risky
git add src/Controller/CustomFieldValueController.php
git commit -m "fix(custom-fields) : prevent creation of orphan CustomField without target entity"
Task 5: Frontend — custom fields definition lookup au mauvais chemin
Probleme: useComponentEdit passe typeComposant.customFields (pas serialise par l'API) au lieu de typeComposant.structure.customFields. Idem usePieceEdit avec typePiece.pieceCustomFields au lieu de typePiece.structure.customFields.
Consequence : le definitionMap est toujours vide, les champs perso sans customFieldId existant ne trouvent pas leur definition et sont envoyes sans definitionId (fallback sur metadata = CustomField orphelin cote backend = Task 4).
Files:
-
Modify:
frontend/app/composables/useComponentEdit.ts:401-403 -
Modify:
frontend/app/composables/usePieceEdit.ts:410-412 -
Step 1: Fix useComponentEdit.ts
Ligne 401-403, remplacer :
[
updatedComponent?.typeComposant?.customFields,
]
par :
[
updatedComponent?.typeComposant?.structure?.customFields,
]
- Step 2: Fix usePieceEdit.ts
Ligne 410-412, remplacer :
[
updatedPiece?.typePiece?.pieceCustomFields,
]
par :
[
updatedPiece?.typePiece?.structure?.customFields,
]
- Step 3: Verifier le meme pattern dans les autres fichiers
Verifier useComponentCreate.ts, pieces/create.vue, product/[id]/edit.vue pour le meme probleme.
- Step 4: Lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
- Step 5: Commit
cd frontend
git add app/composables/useComponentEdit.ts app/composables/usePieceEdit.ts
git commit -m "fix(custom-fields) : use structure.customFields path for definition lookup"
Task 6 (bonus): Verifier et corriger les memes patterns dans create flows
-
Step 1: Grep
_saveCustomFieldValuesdans tous les fichiers et verifier que chaque appel passestructure.customFieldset noncustomFieldsoupieceCustomFieldsdirectement. -
Step 2: Corriger si necessaire, lint, commit.
Ordre d'execution recommande
- T1 (clone) — fix isole, pas de dependance
- T2 (slots validation) — fix isole
- T5 (frontend custom fields path) — fix isole
- T4 (orphan CustomField) — depend de T5 pour comprendre si le frontend utilise l'auto-creation
- T3 (conversion) — le plus complexe, faire en dernier
- T6 (bonus verification)