Compare commits
10 Commits
6e105fd070
...
v1.9.42
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b318ce5d6 | |||
| c10ab08803 | |||
| 85d4726415 | |||
| af13dc0237 | |||
| 7e2cabfa65 | |||
| 003e419a93 | |||
| d1b170d87f | |||
| 0fc9daa974 | |||
| 104942a52b | |||
| c65757ee24 |
@@ -1,7 +1,7 @@
|
||||
api_platform:
|
||||
title: Inventory API
|
||||
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
||||
version: 1.9.6
|
||||
version: 1.9.40
|
||||
defaults:
|
||||
stateless: false
|
||||
cache_headers:
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '1.9.37'
|
||||
app.version: '1.9.42'
|
||||
|
||||
@@ -5,6 +5,19 @@
|
||||
Ajouter une nouvelle machine
|
||||
</h3>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div v-if="errorMessage" class="alert alert-error mb-4" role="alert">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
</svg>
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@@ -78,6 +91,7 @@ const props = defineProps<{
|
||||
sites: Array<{ id: string, name: string }>
|
||||
disabled: boolean
|
||||
preselectedSiteId?: string
|
||||
errorMessage?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
|
||||
export function useMachineCreatePage() {
|
||||
@@ -18,7 +17,6 @@ export function useMachineCreatePage() {
|
||||
|
||||
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const toast = useToast()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local state
|
||||
@@ -27,6 +25,9 @@ export function useMachineCreatePage() {
|
||||
const submitting = ref(false)
|
||||
const loading = ref(true)
|
||||
|
||||
/** Persistent error shown inline in the form (e.g. duplicate name on the same site). */
|
||||
const createError = ref<string | null>(null)
|
||||
|
||||
const newMachine = reactive({
|
||||
name: '',
|
||||
siteId: '',
|
||||
@@ -41,8 +42,10 @@ export function useMachineCreatePage() {
|
||||
const finalizeMachineCreation = async () => {
|
||||
if (submitting.value) return
|
||||
|
||||
createError.value = null
|
||||
|
||||
if (!newMachine.name?.trim()) {
|
||||
toast.showError('Merci de renseigner un nom pour la machine')
|
||||
createError.value = 'Merci de renseigner un nom pour la machine.'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -80,10 +83,10 @@ export function useMachineCreatePage() {
|
||||
await navigateTo('/machines')
|
||||
}
|
||||
} else if (result.error) {
|
||||
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
|
||||
createError.value = humanizeError(result.error)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
|
||||
createError.value = humanizeError(error.message)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -116,6 +119,7 @@ export function useMachineCreatePage() {
|
||||
machines,
|
||||
submitting,
|
||||
loading,
|
||||
createError,
|
||||
|
||||
// Actions
|
||||
finalizeMachineCreation,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -58,7 +58,26 @@
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<div class="form-control md:w-52">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Trier par</span>
|
||||
</label>
|
||||
<select v-model="sortOrder" class="select select-bordered w-full">
|
||||
<option value="name-asc">
|
||||
Nom (A → Z)
|
||||
</option>
|
||||
<option value="name-desc">
|
||||
Nom (Z → A)
|
||||
</option>
|
||||
<option value="date-desc">
|
||||
Plus récentes
|
||||
</option>
|
||||
<option value="date-asc">
|
||||
Plus anciennes
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control md:w-80">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span>
|
||||
</label>
|
||||
@@ -66,13 +85,13 @@
|
||||
<input
|
||||
v-model="dateFrom"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
class="input input-bordered w-full"
|
||||
>
|
||||
<span class="text-xs text-base-content/50">à</span>
|
||||
<input
|
||||
v-model="dateTo"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
class="input input-bordered w-full"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,7 +116,7 @@
|
||||
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
|
||||
Ajouter un site
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="showAddMachineModal = true">
|
||||
<button class="btn btn-ghost btn-sm" @click="openAddMachineModal">
|
||||
Ajouter une machine
|
||||
</button>
|
||||
</div>
|
||||
@@ -263,7 +282,8 @@
|
||||
:sites="sites"
|
||||
:disabled="!canEdit"
|
||||
:preselected-site-id="preselectedSiteId"
|
||||
@close="showAddMachineModal = false"
|
||||
:error-message="addMachineError"
|
||||
@close="closeAddMachineModal"
|
||||
@create="handleCreateMachine"
|
||||
/>
|
||||
</main>
|
||||
@@ -293,8 +313,10 @@ const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
|
||||
// Data
|
||||
const showAddSiteModal = ref(false)
|
||||
const showAddMachineModal = ref(false)
|
||||
const addMachineError = ref(null)
|
||||
const searchTerm = ref('')
|
||||
const selectedSiteFilter = ref('')
|
||||
const sortOrder = ref('name-asc')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const collapsedSites = ref([])
|
||||
@@ -318,10 +340,33 @@ const machinesBySiteId = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
const sortMachines = (machineList) => {
|
||||
const list = [...machineList]
|
||||
switch (sortOrder.value) {
|
||||
case 'name-desc':
|
||||
return list.sort((a, b) =>
|
||||
(b.name || '').localeCompare(a.name || '', 'fr', { sensitivity: 'base', numeric: true })
|
||||
)
|
||||
case 'date-desc':
|
||||
return list.sort((a, b) =>
|
||||
new Date(b.createdAt || 0) - new Date(a.createdAt || 0)
|
||||
)
|
||||
case 'date-asc':
|
||||
return list.sort((a, b) =>
|
||||
new Date(a.createdAt || 0) - new Date(b.createdAt || 0)
|
||||
)
|
||||
case 'name-asc':
|
||||
default:
|
||||
return list.sort((a, b) =>
|
||||
(a.name || '').localeCompare(b.name || '', 'fr', { sensitivity: 'base', numeric: true })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const sitesWithMachines = computed(() => {
|
||||
return sites.value.map((site) => ({
|
||||
...site,
|
||||
machines: machinesBySiteId.value.get(site.id) || []
|
||||
machines: sortMachines(machinesBySiteId.value.get(site.id) || [])
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -406,11 +451,14 @@ const handleCreateSite = async (data) => {
|
||||
}
|
||||
|
||||
const handleCreateMachine = async (data) => {
|
||||
addMachineError.value = null
|
||||
const result = await createMachine(data)
|
||||
|
||||
if (result.success) {
|
||||
showAddMachineModal.value = false
|
||||
await loadMachines()
|
||||
} else if (result.error) {
|
||||
addMachineError.value = humanizeError(result.error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,9 +503,19 @@ const confirmDeleteMachine = async (machine) => {
|
||||
}
|
||||
}
|
||||
|
||||
const openAddMachineModal = () => {
|
||||
addMachineError.value = null
|
||||
showAddMachineModal.value = true
|
||||
}
|
||||
|
||||
const closeAddMachineModal = () => {
|
||||
addMachineError.value = null
|
||||
showAddMachineModal.value = false
|
||||
}
|
||||
|
||||
const addMachineToSite = (site) => {
|
||||
preselectedSiteId.value = site.id
|
||||
showAddMachineModal.value = true
|
||||
openAddMachineModal()
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
</div>
|
||||
|
||||
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
|
||||
<div v-if="c.createError" class="alert alert-error" role="alert">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
</svg>
|
||||
<span>{{ c.createError }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-6">
|
||||
<!-- Basic fields -->
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260527140000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Machine name uniqueness is now scoped per site: drop global unique index on machines(name), add composite unique index on (name, siteid)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Drop the global unique index/constraint on machines(name).
|
||||
// Doctrine-generated name (CRC32 of table+column): uniq_f1ce8ded5e237e06.
|
||||
// It may exist either as a constraint or as a bare index depending on origin,
|
||||
// so we drop defensively in both forms.
|
||||
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS uniq_f1ce8ded5e237e06');
|
||||
$this->addSql('DROP INDEX IF EXISTS uniq_f1ce8ded5e237e06');
|
||||
// Defensive fallbacks for other possible legacy names of the global unique index on name.
|
||||
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS machines_name_key');
|
||||
$this->addSql('DROP INDEX IF EXISTS machines_name_key');
|
||||
|
||||
// New uniqueness scope: a machine name is unique within a given site only.
|
||||
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_machine_name_site ON machines (name, siteid)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP INDEX IF EXISTS uniq_machine_name_site');
|
||||
|
||||
// Best-effort restore of the global unique index on machines(name).
|
||||
// WARNING: this will fail if duplicate names now exist across sites (which the
|
||||
// per-site scope allowed). Resolve duplicates manually before rolling back.
|
||||
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_f1ce8ded5e237e06 ON machines (name)');
|
||||
}
|
||||
}
|
||||
@@ -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\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,28 +290,68 @@ 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.
|
||||
* getId() on a Doctrine proxy does NOT trigger __load(), so we force the proxy
|
||||
* to initialize explicitly to handle orphan links here instead of crashing on
|
||||
* the first real getter.
|
||||
*/
|
||||
private function ensurePieceExists(?Piece $piece): ?Piece
|
||||
{
|
||||
if (null === $piece) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$this->entityManager->initializeObject($piece);
|
||||
|
||||
return $piece;
|
||||
} catch (EntityNotFoundException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* getId() on a Doctrine proxy returns the identifier without triggering __load(),
|
||||
* so it never raises EntityNotFoundException even if the row is gone. Force the
|
||||
* proxy to initialize explicitly so an orphan CFV is handled here instead of
|
||||
* crashing on the first real getter.
|
||||
*/
|
||||
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
|
||||
{
|
||||
if (null === $cf) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$this->entityManager->initializeObject($cf);
|
||||
|
||||
return $cf;
|
||||
} catch (EntityNotFoundException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeCustomFieldValue(CustomFieldValue $value): array
|
||||
{
|
||||
$customField = $value->getCustomField();
|
||||
$customField = $this->ensureCustomFieldExists($value->getCustomField());
|
||||
|
||||
return [
|
||||
'id' => $value->getId(),
|
||||
'value' => $value->getValue(),
|
||||
'customFieldId' => $customField->getId(),
|
||||
'customField' => [
|
||||
'customFieldId' => $customField?->getId(),
|
||||
'customField' => $customField ? [
|
||||
'id' => $customField->getId(),
|
||||
'name' => $customField->getName(),
|
||||
'type' => $customField->getType(),
|
||||
'required' => $customField->isRequired(),
|
||||
'options' => $customField->getOptions(),
|
||||
'orderIndex' => $customField->getOrderIndex(),
|
||||
],
|
||||
] : null,
|
||||
'machineId' => $value->getMachine()?->getId(),
|
||||
'composantId' => $value->getComposant()?->getId(),
|
||||
'pieceId' => $value->getPiece()?->getId(),
|
||||
|
||||
@@ -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,46 @@ class MachineStructureController extends AbstractController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Piece if its underlying row still exists in DB, otherwise null.
|
||||
* getId() on a Doctrine proxy does NOT trigger __load() (the id is the key used
|
||||
* to build the proxy), so we force initialization via initializeObject() to
|
||||
* surface a stale FK here instead of crashing on the first real getter.
|
||||
*/
|
||||
private function ensurePieceExists(?Piece $piece): ?Piece
|
||||
{
|
||||
if (null === $piece) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$this->entityManager->initializeObject($piece);
|
||||
|
||||
return $piece;
|
||||
} catch (EntityNotFoundException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CustomField if its underlying row still exists, otherwise null.
|
||||
* getId() on a Doctrine proxy does NOT trigger __load() — the id is the key used
|
||||
* to build the proxy. We force initialization explicitly so a stale FK to a
|
||||
* deleted CustomField surfaces here instead of crashing on getName() later.
|
||||
*/
|
||||
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
|
||||
{
|
||||
if (null === $cf) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$this->entityManager->initializeObject($cf);
|
||||
|
||||
return $cf;
|
||||
} catch (EntityNotFoundException) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizePiece(Piece $piece): array
|
||||
{
|
||||
$type = $piece->getTypePiece();
|
||||
@@ -920,7 +963,10 @@ class MachineStructureController extends AbstractController
|
||||
if (!$cfv instanceof CustomFieldValue) {
|
||||
continue;
|
||||
}
|
||||
$cf = $cfv->getCustomField();
|
||||
$cf = $this->ensureCustomFieldExists($cfv->getCustomField());
|
||||
if (null === $cf) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $cfv->getId(),
|
||||
'value' => $cfv->getValue(),
|
||||
|
||||
@@ -24,8 +24,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: MachineRepository::class)]
|
||||
#[ORM\Table(name: 'machines')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_machine_name_site', columns: ['name', 'siteId'])]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.', ignoreNull: true)]
|
||||
#[UniqueEntity(fields: ['name', 'site'], message: 'Une machine avec ce nom existe déjà sur ce site.')]
|
||||
#[ApiResource(
|
||||
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.',
|
||||
operations: [
|
||||
@@ -45,7 +47,7 @@ class Machine
|
||||
#[Groups(['document:list'])]
|
||||
private ?string $id = null;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
||||
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||
#[Groups(['document:list'])]
|
||||
private string $name;
|
||||
|
||||
|
||||
@@ -30,8 +30,9 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
|
||||
|
||||
$constraint = $this->detectConstraintName($exception);
|
||||
$error = match ($constraint) {
|
||||
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
|
||||
default => 'Un élément avec cette valeur existe déjà.',
|
||||
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
|
||||
'uniq_machine_name_site' => 'Une machine avec ce nom existe déjà sur ce site.',
|
||||
default => 'Un élément avec cette valeur existe déjà.',
|
||||
};
|
||||
|
||||
$event->setResponse(new JsonResponse(
|
||||
|
||||
@@ -134,6 +134,88 @@ class MachineTest extends AbstractApiTestCase
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
}
|
||||
|
||||
public function testSameNameOnDifferentSitesIsAllowed(): void
|
||||
{
|
||||
$siteA = $this->createSite('Usine A');
|
||||
$siteB = $this->createSite('Usine B');
|
||||
$this->createMachine('Pompe', $siteA);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/machines', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => 'Pompe',
|
||||
'site' => self::iri('sites', $siteB->getId()),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$this->assertJsonContains(['name' => 'Pompe']);
|
||||
}
|
||||
|
||||
public function testSameNameOnSameSiteIsRejected(): void
|
||||
{
|
||||
$site = $this->createSite('Usine');
|
||||
$this->createMachine('Pompe', $site);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('POST', '/api/machines', [
|
||||
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||
'json' => [
|
||||
'name' => 'Pompe',
|
||||
'site' => self::iri('sites', $site->getId()),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$this->assertJsonContains([
|
||||
'violations' => [
|
||||
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testRenameToExistingNameOnSameSiteIsRejected(): void
|
||||
{
|
||||
$site = $this->createSite('Usine');
|
||||
$this->createMachine('Pompe', $site);
|
||||
$other = $this->createMachine('Moteur', $site);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('PATCH', self::iri('machines', $other->getId()), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['name' => 'Pompe'],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$this->assertJsonContains([
|
||||
'violations' => [
|
||||
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testMoveToSiteWhereNameExistsIsRejected(): void
|
||||
{
|
||||
$siteA = $this->createSite('Usine A');
|
||||
$siteB = $this->createSite('Usine B');
|
||||
$this->createMachine('Pompe', $siteB);
|
||||
$machine = $this->createMachine('Pompe', $siteA);
|
||||
|
||||
$client = $this->createGestionnaireClient();
|
||||
$client->request('PATCH', self::iri('machines', $machine->getId()), [
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
'json' => ['site' => self::iri('sites', $siteB->getId())],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(422);
|
||||
$this->assertJsonContains([
|
||||
'violations' => [
|
||||
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function testGetStructureEndpoint(): void
|
||||
{
|
||||
$machine = $this->createMachine('Machine structure');
|
||||
|
||||
Reference in New Issue
Block a user