fix(pieces) : empêche EntityNotFoundException sur Piece orpheline + UX prévention delete

- Migration FK CASCADE/SET NULL pour toutes les FK vers pieces.id (miroir
  de la fix Composant) + cleanup des orphelins existants avec audit log
- Helper ensurePieceExists() qui catch EntityNotFoundException dans
  MachineStructureController et CustomFieldValueController
- Script SQL standalone scripts/cleanup_orphan_piece_refs.sql pour
  nettoyer la prod sans attendre la migration
- Affiche les machines (avec leur site) utilisant la pièce avant la
  confirmation de suppression
This commit is contained in:
Matthieu
2026-05-28 10:08:28 +02:00
parent d1b170d87f
commit 003e419a93
6 changed files with 611 additions and 9 deletions
+22 -1
View File
@@ -6,6 +6,7 @@ namespace App\Controller;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use App\Repository\ComposantRepository;
use App\Repository\CustomFieldRepository;
use App\Repository\CustomFieldValueRepository;
@@ -15,6 +16,7 @@ use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -288,12 +290,31 @@ class CustomFieldValueController extends AbstractController
case 'machinePieceLink':
$value->setMachinePieceLink($entity);
$value->setPiece($entity->getPiece());
$value->setPiece($this->ensurePieceExists($entity->getPiece()));
break;
}
}
/**
* Returns the Piece if its underlying row still exists in DB, otherwise null.
* Forces a lazy proxy to initialize via getId() and swallows EntityNotFoundException
* so an orphan link to a deleted piece doesn't crash custom-field value writes.
*/
private function ensurePieceExists(?Piece $piece): ?Piece
{
if (null === $piece) {
return null;
}
try {
$piece->getId();
return $piece;
} catch (EntityNotFoundException) {
return null;
}
}
private function normalizeCustomFieldValue(CustomFieldValue $value): array
{
$customField = $value->getCustomField();
+28 -6
View File
@@ -26,6 +26,7 @@ use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -676,7 +677,7 @@ class MachineStructureController extends AbstractController
private function normalizePieceLinks(array $links): array
{
return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece();
$piece = $this->ensurePieceExists($link->getPiece());
$modelType = $link->getModelType();
$parentLink = $link->getParentLink();
$type = $piece?->getTypePiece();
@@ -704,7 +705,7 @@ class MachineStructureController extends AbstractController
private function resolvePieceQuantity(MachinePieceLink $link): int
{
$parentLink = $link->getParentLink();
$piece = $link->getPiece();
$piece = $this->ensurePieceExists($link->getPiece());
if (!$parentLink || !$piece) {
return $link->getQuantity();
@@ -716,7 +717,8 @@ class MachineStructureController extends AbstractController
}
foreach ($composant->getPieceSlots() as $slot) {
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
$selected = $this->ensurePieceExists($slot->getSelectedPiece());
if ($selected?->getId() === $piece->getId()) {
return $slot->getQuantity();
}
}
@@ -771,15 +773,16 @@ class MachineStructureController extends AbstractController
{
$pieces = [];
foreach ($composant->getPieceSlots() as $slot) {
$selectedPiece = $this->ensurePieceExists($slot->getSelectedPiece());
$pieceData = [
'slotId' => $slot->getId(),
'typePieceId' => $slot->getTypePiece()?->getId(),
'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
'quantity' => $slot->getQuantity(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
'selectedPieceId' => $selectedPiece?->getId(),
];
if ($slot->getSelectedPiece()) {
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece());
if ($selectedPiece) {
$pieceData['resolvedPiece'] = $this->normalizePiece($selectedPiece);
}
$pieces[] = $pieceData;
}
@@ -810,6 +813,25 @@ class MachineStructureController extends AbstractController
];
}
/**
* Returns the Piece if its underlying row still exists in DB, otherwise null.
* Forces a lazy proxy to initialize via getId() and swallows EntityNotFoundException
* so a stale FK (orphan link to a deleted piece) doesn't crash the whole machine view.
*/
private function ensurePieceExists(?Piece $piece): ?Piece
{
if (null === $piece) {
return null;
}
try {
$piece->getId();
return $piece;
} catch (EntityNotFoundException) {
return null;
}
}
private function normalizePiece(Piece $piece): array
{
$type = $piece->getTypePiece();