Compare commits

..

10 Commits

Author SHA1 Message Date
gitea-actions 85d4726415 chore : bump version to v1.9.41
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 38s
2026-05-28 14:49:28 +00:00
Matthieu af13dc0237 fix(custom-fields) : empêche EntityNotFoundException sur CustomField orphelin
Auto Tag Develop / tag (push) Successful in 9s
Même pattern que la fix Piece (003e419) : helper ensureCustomFieldExists()
qui force l'init du proxy lazy et catch EntityNotFoundException dans
MachineStructureController::normalizeCustomFieldValues() et
CustomFieldValueController::normalizeCustomFieldValue(). Les CFV pointant
vers un CustomField supprimé sont silencieusement skippés au lieu de
crasher la vue machine entière.
2026-05-28 16:48:58 +02:00
Matthieu 7e2cabfa65 chore(release) : v1.9.40
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 1m12s
2026-05-28 10:08:36 +02:00
Matthieu 003e419a93 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
2026-05-28 10:08:28 +02:00
gitea-actions d1b170d87f chore : bump version to v1.9.39
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 1m20s
2026-05-27 13:45:07 +00:00
Matthieu 0fc9daa974 feat(machines) : unicité du nom de machine par site
Auto Tag Develop / tag (push) Successful in 13s
Le nom d'une machine n'est plus unique globalement mais par site :
deux machines peuvent porter le même nom sur des sites différents,
mais le doublon reste interdit sur un même site.

- Machine : contrainte composite (name, siteId) + UniqueEntity (name, site)
- UniqueConstraintSubscriber : message explicite pour uniq_machine_name_site
- Migration : drop index global sur name + create unique index (name, siteid)
- Front : message d'erreur inline explicite à la création (page + modale)
- Tests : 4 scénarios (sites différents / même site / renommage / déplacement)
2026-05-27 15:37:38 +02:00
gitea-actions 104942a52b chore : bump version to v1.9.38
Build & Push Docker Image / build (push) Successful in 2m53s
Auto Tag Develop / tag (push) Successful in 9s
2026-05-21 14:28:44 +00:00
Matthieu c65757ee24 feat(vue-ensemble) : tri alphabétique des machines par défaut + select de tri (nom/date) + harmonisation tailles des champs de filtre
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:23:40 +02:00
Matthieu 6e105fd070 chore : bump version to v1.9.37
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-13 10:50:20 +02:00
Matthieu a0c4597de0 fix(fournisseurs) : ConstructeurSearchFilter utilise EXISTS subquery au lieu de LEFT JOIN
Le LEFT JOIN sur telephones causait une erreur PostgreSQL 'column must appear in GROUP BY' parce que Doctrine sélectionnait aussi les colonnes des téléphones joints. EXISTS subquery corrélée évite la duplication de lignes sans introduire de GROUP BY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:49:43 +02:00
17 changed files with 902 additions and 38 deletions
+1 -1
View File
@@ -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
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '1.9.36'
app.version: '1.9.41'
@@ -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,
+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)
+65 -7
View File
@@ -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
+13
View File
@@ -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')
}
+42
View File
@@ -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);
}
}
+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;
+44 -5
View File
@@ -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,65 @@ class CustomFieldValueController extends AbstractController
case 'machinePieceLink':
$value->setMachinePieceLink($entity);
$value->setPiece($entity->getPiece());
$value->setPiece($this->ensurePieceExists($entity->getPiece()));
break;
}
}
/**
* Returns the Piece if its underlying row still exists in DB, otherwise null.
* Forces a lazy proxy to initialize via getId() and swallows EntityNotFoundException
* so an orphan link to a deleted piece doesn't crash custom-field value writes.
*/
private function ensurePieceExists(?Piece $piece): ?Piece
{
if (null === $piece) {
return null;
}
try {
$piece->getId();
return $piece;
} catch (EntityNotFoundException) {
return null;
}
}
/**
* Same as ensurePieceExists() but for CustomField, to keep orphan CFVs from
* crashing the value endpoints.
*/
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
{
if (null === $cf) {
return null;
}
try {
$cf->getId();
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(),
+51 -7
View File
@@ -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,44 @@ 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;
}
}
/**
* Same defensive pattern as ensurePieceExists() but for CustomField.
* A CustomFieldValue may carry a stale FK to a deleted CustomField; touching
* the lazy proxy raises EntityNotFoundException and 500s the machine view.
*/
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
{
if (null === $cf) {
return null;
}
try {
$cf->getId();
return $cf;
} catch (EntityNotFoundException) {
return null;
}
}
private function normalizePiece(Piece $piece): array
{
$type = $piece->getTypePiece();
@@ -920,7 +961,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(),
+3 -1
View File
@@ -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(
+13 -7
View File
@@ -7,6 +7,7 @@ namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\ConstructeurTelephone;
use Doctrine\ORM\QueryBuilder;
/**
@@ -38,22 +39,27 @@ final class ConstructeurSearchFilter extends AbstractFilter
}
$alias = $queryBuilder->getRootAliases()[0];
$telAlias = $queryNameGenerator->generateJoinAlias('telephones');
$telAlias = $queryNameGenerator->generateJoinAlias('phoneSearch');
$paramName = $queryNameGenerator->generateParameterName('search');
$likePattern = '%'.mb_strtolower(trim($value)).'%';
$em = $queryBuilder->getEntityManager();
$phoneSubQuery = $em->createQueryBuilder()
->select('1')
->from(ConstructeurTelephone::class, $telAlias)
->where(sprintf('%1$s.constructeur = %2$s', $telAlias, $alias))
->andWhere(sprintf('LOWER(%s.numero) LIKE :%s', $telAlias, $paramName))
->getDQL()
;
$queryBuilder
->leftJoin(sprintf('%s.telephones', $alias), $telAlias)
->andWhere(sprintf(
'LOWER(%1$s.name) LIKE :%4$s OR LOWER(%1$s.email) LIKE :%4$s OR LOWER(%2$s.numero) LIKE :%4$s',
'LOWER(%1$s.name) LIKE :%2$s OR LOWER(%1$s.email) LIKE :%2$s OR EXISTS (%3$s)',
$alias,
$telAlias,
'',
$paramName,
$phoneSubQuery,
))
->setParameter($paramName, $likePattern)
;
$queryBuilder->groupBy(sprintf('%s.id', $alias));
}
}
+82
View File
@@ -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');