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 { 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<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
const { confirm } = useConfirm()
const api = useApi()
const handleDeletePiece = async (piece: Record<string, any>) => {
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)
@@ -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')
}