diff --git a/frontend/app/pages/catalogues/pieces.vue b/frontend/app/pages/catalogues/pieces.vue index 71d81c6..fb3c711 100644 --- a/frontend/app/pages/catalogues/pieces.vue +++ b/frontend/app/pages/catalogues/pieces.vue @@ -167,7 +167,7 @@ import { usePieces } from '~/composables/usePieces' import { usePieceTypes } from '~/composables/usePieceTypes' import { useDataTable } from '~/composables/useDataTable' import DocumentThumbnail from '~/components/DocumentThumbnail.vue' -import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils' +import { buildDeleteMessageWithUsage, type UsageInfo } from '~/shared/utils/deleteImpactUtils' import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils' import { formatFrenchDate } from '~/utils/date' @@ -249,10 +249,25 @@ const buildPieceSuppliersDisplay = (piece: Record) => buildSuppliersDisplay(resolveSupplierNames(piece, 'product')) const { confirm } = useConfirm() +const api = useApi() const handleDeletePiece = async (piece: Record) => { const pieceName = piece?.name || 'cette pièce' - const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece)) + + let usage: UsageInfo = {} + try { + const result = await api.get(`/pieces/${piece.id}/used-in`) + if (result.success && result.data) { + usage = { + machines: result.data.machines ?? [], + composants: result.data.composants ?? [], + } + } + } catch (error) { + console.warn('Impossible de récupérer les usages de la pièce avant suppression :', error) + } + + const message = buildDeleteMessageWithUsage(pieceName, 'Cette pièce', usage) const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true }) if (!confirmed) return await deletePiece(piece.id) diff --git a/frontend/app/shared/utils/deleteImpactUtils.ts b/frontend/app/shared/utils/deleteImpactUtils.ts index 7f1289f..6a15f87 100644 --- a/frontend/app/shared/utils/deleteImpactUtils.ts +++ b/frontend/app/shared/utils/deleteImpactUtils.ts @@ -14,6 +14,77 @@ export const buildDeleteMessage = (entityName: string, impacts: string[]): strin if (impacts.length) { lines.push(`Cela supprimera également :\n• ${impacts.join('\n• ')}`) } + lines.push('Cette action est irréversible.') + return lines.join('\n\n') +} + +interface UsedInMachine { + id: string + name: string | null + site?: { id: string; name: string | null } | null +} + +interface UsedInEntity { + id: string + name: string | null +} + +export interface UsageInfo { + machines?: UsedInMachine[] + composants?: UsedInEntity[] + pieces?: UsedInEntity[] +} + +const formatMachineLine = (m: UsedInMachine): string => { + const name = m.name?.trim() || '(sans nom)' + const siteName = m.site?.name?.trim() + return siteName ? `${name} (${siteName})` : name +} + +/** + * Builds a delete-confirmation message that lists the machines (and other + * entities) currently using the item. The user sees exactly what will be + * detached before they confirm the deletion. + */ +export const buildDeleteMessageWithUsage = ( + entityName: string, + entityLabel: string, + usage: UsageInfo, +): string => { + const machines = usage.machines ?? [] + const composants = usage.composants ?? [] + const pieces = usage.pieces ?? [] + + const lines = [`Voulez-vous vraiment supprimer « ${entityName} » ?`] + + if (machines.length > 0) { + const header = machines.length === 1 + ? `${entityLabel} est actuellement utilisée par 1 machine :` + : `${entityLabel} est actuellement utilisée par ${machines.length} machines :` + const bullets = machines.map((m) => `• ${formatMachineLine(m)}`).join('\n') + lines.push(`${header}\n${bullets}\n\nLa supprimer la retirera de ${machines.length === 1 ? 'cette machine' : 'ces machines'}.`) + } + + if (composants.length > 0) { + const header = composants.length === 1 + ? 'Elle est également référencée par 1 composant :' + : `Elle est également référencée par ${composants.length} composants :` + const bullets = composants + .map((c) => `• ${c.name?.trim() || '(sans nom)'}`) + .join('\n') + lines.push(`${header}\n${bullets}`) + } + + if (pieces.length > 0) { + const header = pieces.length === 1 + ? 'Elle est également utilisée par 1 pièce :' + : `Elle est également utilisée par ${pieces.length} pièces :` + const bullets = pieces + .map((p) => `• ${p.name?.trim() || '(sans nom)'}`) + .join('\n') + lines.push(`${header}\n${bullets}`) + } + lines.push('Cette action est irréversible.') return lines.join('\n\n') } \ No newline at end of file diff --git a/migrations/Version20260528090000_FixPieceCascadeFKs.php b/migrations/Version20260528090000_FixPieceCascadeFKs.php new file mode 100644 index 0000000..cbb4fc5 --- /dev/null +++ b/migrations/Version20260528090000_FixPieceCascadeFKs.php @@ -0,0 +1,250 @@ +addSql(<<<'SQL' + INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat) + SELECT + 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), + 'machine_piece_link', + l.id, + 'delete', + json_build_object( + 'id', l.id, + 'machineId', l.machineid, + 'pieceId', l.pieceid, + 'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed' + ), + NULL, + NOW() + FROM machine_piece_links l + WHERE l.pieceid IS NOT NULL + AND l.pieceid NOT IN (SELECT id FROM pieces) + SQL); + + $this->addSql(<<<'SQL' + INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat) + SELECT + 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), + 'piece_product_slot', + s.id, + 'delete', + json_build_object( + 'id', s.id, + 'pieceId', s.pieceid, + 'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed' + ), + NULL, + NOW() + FROM piece_product_slots s + WHERE s.pieceid NOT IN (SELECT id FROM pieces) + SQL); + + $this->addSql(<<<'SQL' + INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat) + SELECT + 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), + 'document', + d.id, + 'delete', + json_build_object( + 'id', d.id, + 'name', d.name, + 'filename', d.filename, + 'pieceId', d.pieceid, + 'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed' + ), + NULL, + NOW() + FROM documents d + WHERE d.pieceid IS NOT NULL + AND d.pieceid NOT IN (SELECT id FROM pieces) + SQL); + + $this->addSql(<<<'SQL' + INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat) + SELECT + 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), + 'custom_field_value', + v.id, + 'delete', + json_build_object( + 'id', v.id, + 'pieceId', v.pieceid, + 'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed' + ), + NULL, + NOW() + FROM custom_field_values v + WHERE v.pieceid IS NOT NULL + AND v.pieceid NOT IN (SELECT id FROM pieces) + SQL); + + $this->addSql(<<<'SQL' + INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat) + SELECT + 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), + 'piece_constructeur_link', + l.id, + 'delete', + json_build_object( + 'id', l.id, + 'pieceId', l.pieceid, + 'constructeurId', l.constructeurid, + 'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed' + ), + NULL, + NOW() + FROM piece_constructeur_links l + WHERE l.pieceid NOT IN (SELECT id FROM pieces) + SQL); + + // ========================================================================= + // 2. Nettoyage des orphelins (avant ADD CONSTRAINT, sinon PG rejette). + // ========================================================================= + $this->addSql(<<<'SQL' + DELETE FROM machine_piece_links + WHERE pieceid IS NOT NULL + AND pieceid NOT IN (SELECT id FROM pieces) + SQL); + + $this->addSql(<<<'SQL' + UPDATE composant_piece_slots SET selectedpieceid = NULL + WHERE selectedpieceid IS NOT NULL + AND selectedpieceid NOT IN (SELECT id FROM pieces) + SQL); + + $this->addSql(<<<'SQL' + DELETE FROM piece_product_slots + WHERE pieceid NOT IN (SELECT id FROM pieces) + SQL); + + $this->addSql(<<<'SQL' + DELETE FROM documents + WHERE pieceid IS NOT NULL + AND pieceid NOT IN (SELECT id FROM pieces) + SQL); + + $this->addSql(<<<'SQL' + DELETE FROM custom_field_values + WHERE pieceid IS NOT NULL + AND pieceid NOT IN (SELECT id FROM pieces) + SQL); + + $this->addSql(<<<'SQL' + DELETE FROM piece_constructeur_links + WHERE pieceid NOT IN (SELECT id FROM pieces) + SQL); + + $this->addSql(<<<'SQL' + DELETE FROM piece_products + WHERE piece_id NOT IN (SELECT id FROM pieces) + SQL); + + // ========================================================================= + // 3. Drop des éventuelles FK existantes vers `pieces` (quel que soit leur + // nom historique), puis ADD CONSTRAINT avec le bon ON DELETE. + // ========================================================================= + $this->dropFksReferencingPieces('machine_piece_links', 'pieceid'); + $this->addSql(<<<'SQL' + ALTER TABLE machine_piece_links ADD CONSTRAINT fk_mpl_piece + FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE + SQL); + + $this->dropFksReferencingPieces('composant_piece_slots', 'selectedpieceid'); + $this->addSql(<<<'SQL' + ALTER TABLE composant_piece_slots ADD CONSTRAINT fk_cps_selected_piece + FOREIGN KEY (selectedpieceid) REFERENCES pieces(id) ON DELETE SET NULL + SQL); + + $this->dropFksReferencingPieces('piece_product_slots', 'pieceid'); + $this->addSql(<<<'SQL' + ALTER TABLE piece_product_slots ADD CONSTRAINT fk_pps_piece + FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE + SQL); + + $this->dropFksReferencingPieces('documents', 'pieceid'); + $this->addSql(<<<'SQL' + ALTER TABLE documents ADD CONSTRAINT fk_documents_piece + FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE + SQL); + + $this->dropFksReferencingPieces('custom_field_values', 'pieceid'); + $this->addSql(<<<'SQL' + ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_piece + FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE + SQL); + + $this->dropFksReferencingPieces('piece_constructeur_links', 'pieceid'); + $this->addSql(<<<'SQL' + ALTER TABLE piece_constructeur_links ADD CONSTRAINT fk_pcl_piece + FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_mpl_piece'); + $this->addSql('ALTER TABLE composant_piece_slots DROP CONSTRAINT IF EXISTS fk_cps_selected_piece'); + $this->addSql('ALTER TABLE piece_product_slots DROP CONSTRAINT IF EXISTS fk_pps_piece'); + $this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_piece'); + $this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_piece'); + $this->addSql('ALTER TABLE piece_constructeur_links DROP CONSTRAINT IF EXISTS fk_pcl_piece'); + } + + /** + * Drop every FK on $table.$column that references the `pieces` table, + * regardless of its historic name. Idempotent. + */ + private function dropFksReferencingPieces(string $table, string $column): void + { + $sql = <<addSql($sql); + } +} diff --git a/scripts/cleanup_orphan_piece_refs.sql b/scripts/cleanup_orphan_piece_refs.sql new file mode 100644 index 0000000..c445875 --- /dev/null +++ b/scripts/cleanup_orphan_piece_refs.sql @@ -0,0 +1,223 @@ +-- ============================================================================= +-- cleanup_orphan_piece_refs.sql +-- ============================================================================= +-- Contexte : la suppression directe de rows dans `pieces` (bypass Doctrine / +-- FK DB sans ON DELETE CASCADE) laisse des références orphelines dans plusieurs +-- tables, ce qui fait planter l'API au chargement d'une Machine : +-- Doctrine\ORM\EntityNotFoundException: Entity of type 'App\Entity\Piece' ... +-- +-- Ce script fait deux choses : +-- 1. ÉTAPE 1 (toujours exécutée) : compte les références orphelines par table +-- pour visualiser l'ampleur du problème. +-- 2. ÉTAPE 2 (commentée par défaut) : insère un audit_log par row, puis +-- DELETE / UPDATE SET NULL selon la sémantique attendue côté entité. +-- Décommenter le bloc `BEGIN; ... COMMIT;` pour appliquer. +-- +-- Usage : +-- # Dry-run (compte seulement) +-- psql -h -U -d inventory -f scripts/cleanup_orphan_piece_refs.sql +-- +-- # Application : décommenter le bloc transactionnel en bas du fichier, +-- # puis relancer la même commande. La transaction garantit l'atomicité. +-- ============================================================================= + + +-- ============================== ÉTAPE 1 : DRY-RUN ============================ +\echo '' +\echo '=== Orphelins par table (Pieces) ===' + +SELECT 'machine_piece_links' AS table_name, count(*) AS orphans + FROM machine_piece_links + WHERE pieceid IS NOT NULL + AND pieceid NOT IN (SELECT id FROM pieces) +UNION ALL +SELECT 'composant_piece_slots', count(*) + FROM composant_piece_slots + WHERE selectedpieceid IS NOT NULL + AND selectedpieceid NOT IN (SELECT id FROM pieces) +UNION ALL +SELECT 'piece_product_slots', count(*) + FROM piece_product_slots + WHERE pieceid NOT IN (SELECT id FROM pieces) +UNION ALL +SELECT 'documents', count(*) + FROM documents + WHERE pieceid IS NOT NULL + AND pieceid NOT IN (SELECT id FROM pieces) +UNION ALL +SELECT 'custom_field_values', count(*) + FROM custom_field_values + WHERE pieceid IS NOT NULL + AND pieceid NOT IN (SELECT id FROM pieces) +UNION ALL +SELECT 'piece_constructeur_links', count(*) + FROM piece_constructeur_links + WHERE pieceid NOT IN (SELECT id FROM pieces) +UNION ALL +SELECT 'piece_products', count(*) + FROM piece_products + WHERE piece_id NOT IN (SELECT id FROM pieces) +ORDER BY table_name; + +\echo '' +\echo '=> Pour appliquer le cleanup, décommenter le bloc BEGIN/COMMIT ci-dessous.' +\echo '' + + +-- ============================== ÉTAPE 2 : APPLY ============================= +-- Décommenter ce bloc pour exécuter le cleanup. La transaction garantit +-- l'atomicité : tout passe, ou rien (en cas d'erreur, ROLLBACK auto). +-- +-- BEGIN; +-- +-- -- 1. Audit log : snapshot des rows qui vont être supprimées (traçabilité prod). +-- +-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat) +-- SELECT +-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), +-- 'machine_piece_link', +-- l.id, +-- 'delete', +-- json_build_object( +-- 'id', l.id, +-- 'machineId', l.machineid, +-- 'pieceId', l.pieceid, +-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed' +-- ), +-- NULL, +-- NOW() +-- FROM machine_piece_links l +-- WHERE l.pieceid IS NOT NULL +-- AND l.pieceid NOT IN (SELECT id FROM pieces); +-- +-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat) +-- SELECT +-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), +-- 'piece_product_slot', +-- s.id, +-- 'delete', +-- json_build_object( +-- 'id', s.id, +-- 'pieceId', s.pieceid, +-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed' +-- ), +-- NULL, +-- NOW() +-- FROM piece_product_slots s +-- WHERE s.pieceid NOT IN (SELECT id FROM pieces); +-- +-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat) +-- SELECT +-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), +-- 'document', +-- d.id, +-- 'delete', +-- json_build_object( +-- 'id', d.id, +-- 'name', d.name, +-- 'filename', d.filename, +-- 'pieceId', d.pieceid, +-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed' +-- ), +-- NULL, +-- NOW() +-- FROM documents d +-- WHERE d.pieceid IS NOT NULL +-- AND d.pieceid NOT IN (SELECT id FROM pieces); +-- +-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat) +-- SELECT +-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), +-- 'custom_field_value', +-- v.id, +-- 'delete', +-- json_build_object( +-- 'id', v.id, +-- 'pieceId', v.pieceid, +-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed' +-- ), +-- NULL, +-- NOW() +-- FROM custom_field_values v +-- WHERE v.pieceid IS NOT NULL +-- AND v.pieceid NOT IN (SELECT id FROM pieces); +-- +-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat) +-- SELECT +-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24), +-- 'piece_constructeur_link', +-- l.id, +-- 'delete', +-- json_build_object( +-- 'id', l.id, +-- 'pieceId', l.pieceid, +-- 'constructeurId', l.constructeurid, +-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed' +-- ), +-- NULL, +-- NOW() +-- FROM piece_constructeur_links l +-- WHERE l.pieceid NOT IN (SELECT id FROM pieces); +-- +-- -- 2. Nettoyage des orphelins. +-- +-- DELETE FROM machine_piece_links +-- WHERE pieceid IS NOT NULL +-- AND pieceid NOT IN (SELECT id FROM pieces); +-- +-- UPDATE composant_piece_slots SET selectedpieceid = NULL +-- WHERE selectedpieceid IS NOT NULL +-- AND selectedpieceid NOT IN (SELECT id FROM pieces); +-- +-- DELETE FROM piece_product_slots +-- WHERE pieceid NOT IN (SELECT id FROM pieces); +-- +-- DELETE FROM documents +-- WHERE pieceid IS NOT NULL +-- AND pieceid NOT IN (SELECT id FROM pieces); +-- +-- DELETE FROM custom_field_values +-- WHERE pieceid IS NOT NULL +-- AND pieceid NOT IN (SELECT id FROM pieces); +-- +-- DELETE FROM piece_constructeur_links +-- WHERE pieceid NOT IN (SELECT id FROM pieces); +-- +-- DELETE FROM piece_products +-- WHERE piece_id NOT IN (SELECT id FROM pieces); +-- +-- -- 3. Vérification post-cleanup : tout doit être à 0. +-- SELECT 'machine_piece_links' AS table_name, count(*) AS remaining_orphans +-- FROM machine_piece_links +-- WHERE pieceid IS NOT NULL +-- AND pieceid NOT IN (SELECT id FROM pieces) +-- UNION ALL +-- SELECT 'composant_piece_slots', count(*) +-- FROM composant_piece_slots +-- WHERE selectedpieceid IS NOT NULL +-- AND selectedpieceid NOT IN (SELECT id FROM pieces) +-- UNION ALL +-- SELECT 'piece_product_slots', count(*) +-- FROM piece_product_slots +-- WHERE pieceid NOT IN (SELECT id FROM pieces) +-- UNION ALL +-- SELECT 'documents', count(*) +-- FROM documents +-- WHERE pieceid IS NOT NULL +-- AND pieceid NOT IN (SELECT id FROM pieces) +-- UNION ALL +-- SELECT 'custom_field_values', count(*) +-- FROM custom_field_values +-- WHERE pieceid IS NOT NULL +-- AND pieceid NOT IN (SELECT id FROM pieces) +-- UNION ALL +-- SELECT 'piece_constructeur_links', count(*) +-- FROM piece_constructeur_links +-- WHERE pieceid NOT IN (SELECT id FROM pieces) +-- UNION ALL +-- SELECT 'piece_products', count(*) +-- FROM piece_products +-- WHERE piece_id NOT IN (SELECT id FROM pieces) +-- ORDER BY table_name; +-- +-- COMMIT; diff --git a/src/Controller/CustomFieldValueController.php b/src/Controller/CustomFieldValueController.php index d614db2..34ea823 100644 --- a/src/Controller/CustomFieldValueController.php +++ b/src/Controller/CustomFieldValueController.php @@ -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(); diff --git a/src/Controller/MachineStructureController.php b/src/Controller/MachineStructureController.php index 4859235..602ac9d 100644 --- a/src/Controller/MachineStructureController.php +++ b/src/Controller/MachineStructureController.php @@ -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();