Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e2cabfa65 | |||
| 003e419a93 |
@@ -1,7 +1,7 @@
|
|||||||
api_platform:
|
api_platform:
|
||||||
title: Inventory API
|
title: Inventory API
|
||||||
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
||||||
version: 1.9.6
|
version: 1.9.40
|
||||||
defaults:
|
defaults:
|
||||||
stateless: false
|
stateless: false
|
||||||
cache_headers:
|
cache_headers:
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '1.9.39'
|
app.version: '1.9.40'
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user