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
+17 -2
View File
@@ -167,7 +167,7 @@ import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { useDataTable } from '~/composables/useDataTable' import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' 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 { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date' import { formatFrenchDate } from '~/utils/date'
@@ -249,10 +249,25 @@ const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(piece, 'product')) buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
const { confirm } = useConfirm() const { confirm } = useConfirm()
const api = useApi()
const handleDeletePiece = async (piece: Record<string, any>) => { const handleDeletePiece = async (piece: Record<string, any>) => {
const pieceName = piece?.name || 'cette pièce' 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 }) const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
if (!confirmed) return if (!confirmed) return
await deletePiece(piece.id) await deletePiece(piece.id)
@@ -14,6 +14,77 @@ export const buildDeleteMessage = (entityName: string, impacts: string[]): strin
if (impacts.length) { if (impacts.length) {
lines.push(`Cela supprimera également :\n• ${impacts.join('\n• ')}`) 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.') lines.push('Cette action est irréversible.')
return lines.join('\n\n') return lines.join('\n\n')
} }
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Align all FKs pointing to `pieces.id` with what entities declare
* (ON DELETE CASCADE / SET NULL). Cleans up pre-existing orphan rows
* inserted before the constraints existed, so the new FKs can be added.
*
* Mirror of Version20260506140000_FixComposantCascadeFKs for the Piece side.
*/
final class Version20260528090000_FixPieceCascadeFKs extends AbstractMigration
{
public function getDescription(): string
{
return 'Align CASCADE/SET NULL FKs on pieces references (machine_piece_links, composant_piece_slots, piece_product_slots, documents, custom_field_values, piece_constructeur_links); cleanup pre-existing orphans';
}
public function up(Schema $schema): void
{
// =========================================================================
// 1. Audit log : snapshot des rows orphelines avant suppression.
// =========================================================================
$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),
'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 = <<<SQL
DO \$\$
DECLARE
fk_name TEXT;
BEGIN
FOR fk_name IN
SELECT tc.constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON kcu.constraint_name = tc.constraint_name
AND kcu.table_schema = tc.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.table_name = '{$table}'
AND tc.constraint_type = 'FOREIGN KEY'
AND kcu.column_name = '{$column}'
AND ccu.table_name = 'pieces'
LOOP
EXECUTE format('ALTER TABLE {$table} DROP CONSTRAINT %I', fk_name);
END LOOP;
END \$\$;
SQL;
$this->addSql($sql);
}
}
+223
View File
@@ -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 <host> -U <user> -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;
+22 -1
View File
@@ -6,6 +6,7 @@ namespace App\Controller;
use App\Entity\CustomField; use App\Entity\CustomField;
use App\Entity\CustomFieldValue; use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use App\Repository\ComposantRepository; use App\Repository\ComposantRepository;
use App\Repository\CustomFieldRepository; use App\Repository\CustomFieldRepository;
use App\Repository\CustomFieldValueRepository; use App\Repository\CustomFieldValueRepository;
@@ -15,6 +16,7 @@ use App\Repository\MachineRepository;
use App\Repository\PieceRepository; use App\Repository\PieceRepository;
use App\Repository\ProductRepository; use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -288,12 +290,31 @@ class CustomFieldValueController extends AbstractController
case 'machinePieceLink': case 'machinePieceLink':
$value->setMachinePieceLink($entity); $value->setMachinePieceLink($entity);
$value->setPiece($entity->getPiece()); $value->setPiece($this->ensurePieceExists($entity->getPiece()));
break; 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 private function normalizeCustomFieldValue(CustomFieldValue $value): array
{ {
$customField = $value->getCustomField(); $customField = $value->getCustomField();
+28 -6
View File
@@ -26,6 +26,7 @@ use App\Repository\PieceRepository;
use App\Repository\ProductRepository; use App\Repository\ProductRepository;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -676,7 +677,7 @@ class MachineStructureController extends AbstractController
private function normalizePieceLinks(array $links): array private function normalizePieceLinks(array $links): array
{ {
return array_map(function (MachinePieceLink $link): array { return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece(); $piece = $this->ensurePieceExists($link->getPiece());
$modelType = $link->getModelType(); $modelType = $link->getModelType();
$parentLink = $link->getParentLink(); $parentLink = $link->getParentLink();
$type = $piece?->getTypePiece(); $type = $piece?->getTypePiece();
@@ -704,7 +705,7 @@ class MachineStructureController extends AbstractController
private function resolvePieceQuantity(MachinePieceLink $link): int private function resolvePieceQuantity(MachinePieceLink $link): int
{ {
$parentLink = $link->getParentLink(); $parentLink = $link->getParentLink();
$piece = $link->getPiece(); $piece = $this->ensurePieceExists($link->getPiece());
if (!$parentLink || !$piece) { if (!$parentLink || !$piece) {
return $link->getQuantity(); return $link->getQuantity();
@@ -716,7 +717,8 @@ class MachineStructureController extends AbstractController
} }
foreach ($composant->getPieceSlots() as $slot) { foreach ($composant->getPieceSlots() as $slot) {
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) { $selected = $this->ensurePieceExists($slot->getSelectedPiece());
if ($selected?->getId() === $piece->getId()) {
return $slot->getQuantity(); return $slot->getQuantity();
} }
} }
@@ -771,15 +773,16 @@ class MachineStructureController extends AbstractController
{ {
$pieces = []; $pieces = [];
foreach ($composant->getPieceSlots() as $slot) { foreach ($composant->getPieceSlots() as $slot) {
$selectedPiece = $this->ensurePieceExists($slot->getSelectedPiece());
$pieceData = [ $pieceData = [
'slotId' => $slot->getId(), 'slotId' => $slot->getId(),
'typePieceId' => $slot->getTypePiece()?->getId(), 'typePieceId' => $slot->getTypePiece()?->getId(),
'typePiece' => $this->normalizeModelType($slot->getTypePiece()), 'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
'quantity' => $slot->getQuantity(), 'quantity' => $slot->getQuantity(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(), 'selectedPieceId' => $selectedPiece?->getId(),
]; ];
if ($slot->getSelectedPiece()) { if ($selectedPiece) {
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece()); $pieceData['resolvedPiece'] = $this->normalizePiece($selectedPiece);
} }
$pieces[] = $pieceData; $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 private function normalizePiece(Piece $piece): array
{ {
$type = $piece->getTypePiece(); $type = $piece->getTypePiece();