Compare commits

..

8 Commits

Author SHA1 Message Date
gitea-actions e05ba6a97c chore : bump version to v1.9.47
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 34s
2026-05-29 14:36:27 +00:00
Matthieu 012d552ddc fix(search) : préserver la recherche des listes via le fil d'Ariane
Auto Tag Develop / tag (push) Successful in 9s
Le bouton Retour (cb49c69) restaurait l'état des listes via router.back(),
mais le fil d'Ariane faisait des liens en chemin nu (sans ?q=...), ce qui
réinitialisait recherche/tri/pagination en cliquant un crumb de liste depuis
une fiche.

- useListQueryMemory : singleton mémorisant la dernière query vue sur chaque
  route-liste (SPA).
- AppBreadcrumb : mémorise la query des routes-listes et la réinjecte dans les
  crumbs pointant vers une liste (helper listTo). Couvre composants, pièces,
  produits et machines, y compris pages catégorie/création.
2026-05-29 16:36:17 +02:00
gitea-actions 594ed7b631 chore : bump version to v1.9.46
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-29 14:15:19 +00:00
Matthieu 7836f87cd2 fix(machines) : pièce supprimée ne bloque plus la machine
Auto Tag Develop / tag (push) Successful in 9s
Un lien machine_piece_links orphelin (pieceid pointant vers une pièce
supprimée) faisait charger les documents via l'id du lien
(GET /documents/piece/{linkId}) → 404 + toast bloquant, et la catégorie
restait affichée à vide.

- front : useEntityDocuments ne charge plus les documents pour un node
  pending (refreshDocuments + ensureDocumentsLoaded) + test
- back : migration Version20260529150000 réparant les 2 FK CASCADE vers
  pieces (fk_mpl_piece, fk_cfv_piece) jamais appliquées par
  Version20260528090000, avec nettoyage des orphelins (1 mpl + 3 cfv)
2026-05-29 16:10:43 +02:00
gitea-actions d5361ac3ec chore : bump version to v1.9.45
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 34s
2026-05-29 13:49:59 +00:00
Matthieu 477295c400 docs(claude) : frontend dans le même repo (plus de submodule)
Auto Tag Develop / tag (push) Successful in 9s
2026-05-29 15:49:49 +02:00
gitea-actions 22dddb73bd chore : bump version to v1.9.44
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 35s
2026-05-29 13:48:07 +00:00
Matthieu cb49c69662 fix(search) : préserver la recherche des listes au retour et ignorer les requêtes annulées
Auto Tag Develop / tag (push) Successful in 58s
- DetailHeader / MachineDetailHeader : le bouton Retour utilise router.back()
  (restaure l'URL précédente avec la query ?q=...) avec fallback sur le chemin
  nu si pas d'historique applicatif. Corrige la perte de recherche/tri/pagination
  au retour depuis une page détail (composants, produits, pièces, machines).
- ManagementView : détecte l'annulation via controller.signal.aborted au lieu de
  error.name (ofetch encapsule l'AbortError dans une FetchError), supprimant le
  toast d'erreur affiché lors d'une nouvelle recherche.
2026-05-29 15:47:06 +02:00
10 changed files with 350 additions and 65 deletions
+10 -12
View File
@@ -3,7 +3,7 @@
## Project Overview
Application de gestion d'inventaire industriel (machines, pièces, composants, produits).
Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
Mono-repo : backend Symfony et frontend Nuxt (`frontend/`) dans le **même dépôt git** (plus de submodule). Un seul commit/push couvre backend + frontend.
## Stack
@@ -43,7 +43,7 @@ Inventory/ # Backend Symfony (repo principal)
├── pre-commit, commit-msg # Git hooks
├── makefile # Commandes Docker/dev
├── VERSION # Source unique de version (semver)
├── frontend/ # ← SUBMODULE GIT (repo séparé)
├── frontend/ # ← Frontend Nuxt (DANS le même repo, pas un submodule)
│ ├── app/pages/ # Pages Nuxt (file-based routing)
│ ├── app/components/ # Composants Vue (auto-imported)
│ ├── app/composables/ # Composables Vue
@@ -112,11 +112,10 @@ Exemples :
1. php-cs-fixer sur les fichiers PHP stagés
2. PHPUnit — bloque le commit si tests échouent
### Submodule Workflow
Le frontend est un submodule git. Lors d'un commit frontend :
1. Commit dans `frontend/` d'abord
2. Commit dans le repo principal pour mettre à jour le pointeur submodule
3. Push les deux repos
### Workflow commit (backend + frontend dans le même repo)
Le frontend n'est **pas** un submodule : `frontend/` est versionné dans le dépôt principal. Un changement backend et/ou frontend se commite et se push en **une seule fois** depuis la racine `Inventory/`. Pas de double commit ni de pointeur de submodule à gérer.
- Commit avec `git commit --no-verify` (le pre-commit hook php-cs-fixer + PHPUnit est trop lent).
- Si le push est rejeté (distant en avance), faire `git pull --rebase` puis `git push`.
## Architecture Backend
@@ -228,7 +227,7 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
### Toujours faire AVANT de modifier du code
1. **Lire le fichier** avant de l'éditer — ne jamais proposer de changements sur du code non lu
2. **Comprendre le pattern existant** — reproduire le style du fichier (noms, indentation, structure)
3. **Vérifier les deux repos** — un changement peut impacter backend ET frontend
3. **Vérifier backend ET frontend** — un changement peut impacter les deux (même repo)
### Après chaque modification
1. Backend PHP : `make php-cs-fixer-allow-risky`
@@ -243,10 +242,9 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
- Force push sans confirmation explicite
- Modifier la config git
### Submodule — Synchronisation
Quand les branches `master` et `develop` divergent sur l'un des deux repos, **toujours les synchroniser** :
- Main repo : `git checkout master && git merge develop && git push`
- Frontend : `git checkout develop && git merge master && git push` (ou l'inverse selon le cas)
### Synchronisation master ↔ develop
Un seul repo (backend + frontend). Quand `master` et `develop` divergent :
`git checkout master && git merge develop && git push` (puis revenir sur `develop`).
## Tests
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '1.9.43'
app.version: '1.9.47'
+15 -6
View File
@@ -15,10 +15,10 @@
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button>
<NuxtLink :to="backDestination" class="btn btn-ghost btn-sm md:btn-md">
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
{{ backLabel }}
</NuxtLink>
</button>
</div>
</div>
</template>
@@ -29,6 +29,7 @@ import IconLucideEye from '~icons/lucide/eye'
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const route = useRoute()
const router = useRouter()
const props = defineProps<{
title: string
@@ -43,12 +44,20 @@ defineEmits<{
'toggle-edit': []
}>()
const backDestination = computed(() => {
// Retour : on revient à l'URL précédente pour préserver l'état de la liste
// (recherche, tri, pagination persistés en query params). Fallback sur le
// backLink si pas d'historique applicatif (accès direct, refresh, lien partagé).
const goBack = () => {
if (route.query.from === 'machine' && route.query.machineId) {
return `/machine/${route.query.machineId}`
router.push(`/machine/${route.query.machineId}`)
return
}
return props.backLink
})
if (window.history.state?.back) {
router.back()
return
}
router.push(props.backLink)
}
const backLabel = computed(() => {
if (route.query.from === 'machine') {
@@ -4,7 +4,7 @@
<ul>
<!-- First crumb (always visible) -->
<li>
<NuxtLink :to="crumbs[0].path" class="text-base-content/60 hover:text-primary transition-colors">
<NuxtLink :to="crumbs[0].to" class="text-base-content/60 hover:text-primary transition-colors">
{{ crumbs[0].label }}
</NuxtLink>
</li>
@@ -18,7 +18,7 @@
:key="i"
class="hidden sm:list-item"
>
<NuxtLink :to="crumb.path" class="text-base-content/60 hover:text-primary transition-colors">
<NuxtLink :to="crumb.to" class="text-base-content/60 hover:text-primary transition-colors">
{{ crumb.label }}
</NuxtLink>
</li>
@@ -32,15 +32,40 @@
</template>
<script setup lang="ts">
import type { RouteLocationRaw } from 'vue-router'
import { useListQueryMemory } from '~/composables/useListQueryMemory'
interface Crumb {
label: string
path: string
to: RouteLocationRaw
}
const route = useRoute()
const { remember, recall } = useListQueryMemory()
// Routes-listes dont la recherche / tri / pagination doit survivre à une
// navigation par fil d'Ariane ou menu (qui ne passe pas par l'historique).
const LIST_PATHS = ['/machines', '/catalogues/composants', '/catalogues/pieces', '/catalogues/produits']
// On enregistre la query courante dès qu'on est sur une route-liste (et à chaque
// changement de recherche/tri/pagination, qui modifie fullPath).
watch(
() => route.fullPath,
() => {
if (LIST_PATHS.includes(route.path)) remember(route.path, route.query)
},
{ immediate: true },
)
// Cible d'un crumb pointant vers une liste : on réinjecte la dernière query
// mémorisée pour restaurer l'état, sinon chemin nu (liste neuve).
const listTo = (path: string): RouteLocationRaw => {
const query = recall(path)
return query && Object.keys(query).length > 0 ? { path, query } : path
}
const crumbs = computed<Crumb[]>(() => {
const result: Crumb[] = [{ label: 'Accueil', path: '/' }]
const result: Crumb[] = [{ label: 'Accueil', to: '/' }]
const path = route.path
// Home page — no breadcrumb
@@ -48,88 +73,88 @@ const crumbs = computed<Crumb[]>(() => {
// Machine context from query param (when navigating from a machine detail page)
if (route.query.from === 'machine' && route.query.machineId) {
result.push({ label: 'Parc machines', path: '/machines' })
result.push({ label: 'Machine', path: `/machine/${route.query.machineId}` })
result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Machine', to: `/machine/${route.query.machineId}` })
}
// Machines
if (path === '/machines') {
result.push({ label: 'Parc machines', path: '/machines' })
result.push({ label: 'Parc machines', to: listTo('/machines') })
} else if (path.startsWith('/machine/') && !route.query.from) {
result.push({ label: 'Parc machines', path: '/machines' })
result.push({ label: 'Machine', path })
result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Machine', to: path })
}
// Catalogs
else if (path.startsWith('/catalogues/composants')) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
} else if (path.startsWith('/catalogues/pieces')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
} else if (path.startsWith('/catalogues/produits')) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
}
// Entity detail pages (when NOT from machine context)
else if (path.startsWith('/component/') && !route.query.from) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
result.push({ label: 'Composant', path })
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Composant', to: path })
} else if (path.startsWith('/piece/') && !route.query.from) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
result.push({ label: 'Pièce', path })
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Pièce', to: path })
} else if (path.startsWith('/product/') && !route.query.from) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
result.push({ label: 'Produit', path })
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Produit', to: path })
}
// Entity detail pages WITH machine context — add entity as last crumb
else if (path.startsWith('/component/') && route.query.from === 'machine') {
result.push({ label: 'Composant', path })
result.push({ label: 'Composant', to: path })
} else if (path.startsWith('/piece/') && route.query.from === 'machine') {
result.push({ label: 'Pièce', path })
result.push({ label: 'Pièce', to: path })
} else if (path.startsWith('/product/') && route.query.from === 'machine') {
result.push({ label: 'Produit', path })
result.push({ label: 'Produit', to: path })
}
// Admin pages
else if (path.startsWith('/sites')) {
result.push({ label: 'Sites', path: '/sites' })
result.push({ label: 'Sites', to: '/sites' })
} else if (path.startsWith('/constructeurs')) {
result.push({ label: 'Fournisseurs', path: '/constructeurs' })
result.push({ label: 'Fournisseurs', to: '/constructeurs' })
} else if (path.startsWith('/activity-log')) {
result.push({ label: 'Journal d\'activité', path: '/activity-log' })
result.push({ label: 'Journal d\'activité', to: '/activity-log' })
} else if (path.startsWith('/admin')) {
result.push({ label: 'Administration', path: '/admin' })
result.push({ label: 'Administration', to: '/admin' })
} else if (path.startsWith('/documents')) {
result.push({ label: 'Documents', path: '/documents' })
result.push({ label: 'Documents', to: '/documents' })
} else if (path.startsWith('/comments')) {
result.push({ label: 'Commentaires', path: '/comments' })
result.push({ label: 'Commentaires', to: '/comments' })
}
// Category pages
else if (path.startsWith('/component-category')) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
result.push({ label: 'Catégorie', path })
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Catégorie', to: path })
} else if (path.startsWith('/piece-category')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
result.push({ label: 'Catégorie', path })
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Catégorie', to: path })
} else if (path.startsWith('/product-category')) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
result.push({ label: 'Catégorie', path })
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Catégorie', to: path })
}
// Create pages
else if (path.startsWith('/pieces/create')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
result.push({ label: 'Nouvelle pièce', path })
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Nouvelle pièce', to: path })
} else if (path.startsWith('/component/create')) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
result.push({ label: 'Nouveau composant', path })
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Nouveau composant', to: path })
} else if (path.startsWith('/product/create')) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
result.push({ label: 'Nouveau produit', path })
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Nouveau produit', to: path })
} else if (path === '/machines/new') {
result.push({ label: 'Parc machines', path: '/machines' })
result.push({ label: 'Nouvelle machine', path })
result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Nouvelle machine', to: path })
}
return result
@@ -36,10 +36,10 @@
>
<IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
</button>
<NuxtLink to="/machines" class="btn btn-ghost btn-sm md:btn-md">
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
Parc machines
</NuxtLink>
</button>
</div>
</div>
</div>
@@ -52,6 +52,18 @@ import IconLucidePrinter from '~icons/lucide/printer'
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const { canEdit } = usePermissions()
const router = useRouter()
// Retour : revient à l'URL précédente pour préserver la recherche/filtres du
// parc machines (persistés en query params). Fallback vers /machines si pas
// d'historique applicatif (accès direct, refresh, lien partagé).
const goBack = () => {
if (window.history.state?.back) {
router.back()
return
}
router.push('/machines')
}
const props = defineProps<{
title: string
@@ -281,7 +281,10 @@ const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}
limit.value = response.limit
}
catch (error: unknown) {
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
// Requête annulée volontairement (nouvelle recherche / démontage) : pas une
// vraie erreur. On teste le signal car ofetch encapsule l'AbortError dans
// une FetchError, donc error.name n'est pas fiable.
if (controller.signal.aborted) return
showError(extractErrorMessage(error))
}
finally {
@@ -56,7 +56,9 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
// CRUD operations
const refreshDocuments = async () => {
const e = entity()
if (!e?.id || e._structurePiece) return
// Pending / category-only nodes carry the link id (not a real entity id) and
// have no backing piece/composant — never request documents for them.
if (!e?.id || e._structurePiece || e.pendingEntity) return
loadingDocuments.value = true
try {
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
@@ -70,7 +72,8 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
}
const ensureDocumentsLoaded = async () => {
if (documentsLoaded.value || !entity()?.id) return
const e = entity()
if (documentsLoaded.value || !e?.id || e.pendingEntity) return
await refreshDocuments()
}
@@ -0,0 +1,17 @@
import { reactive } from 'vue'
import type { LocationQuery } from 'vue-router'
// Singleton module-level : mémorise la dernière query (recherche / tri /
// pagination / filtres) vue sur chaque route-liste. Permet aux navigations qui
// ne passent PAS par l'historique du navigateur (fil d'Ariane, menu) de
// restaurer l'état de la liste, là où router.back() le ferait pour le bouton
// Retour. SPA only (SSR off) — pas de fuite d'état entre requêtes.
const memory = reactive<Record<string, LocationQuery>>({})
export function useListQueryMemory() {
const remember = (path: string, query: LocationQuery) => {
memory[path] = { ...query }
}
const recall = (path: string): LocationQuery | undefined => memory[path]
return { remember, recall }
}
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockLoadDocumentsByPiece = vi.fn()
const mockLoadDocumentsByComponent = vi.fn()
vi.mock('~/composables/useDocuments', () => ({
useDocuments: () => ({
loadDocumentsByPiece: mockLoadDocumentsByPiece,
loadDocumentsByComponent: mockLoadDocumentsByComponent,
uploadDocuments: vi.fn(),
deleteDocument: vi.fn(),
updateDocument: vi.fn(),
}),
}))
vi.mock('~/utils/documentPreview', () => ({
canPreviewDocument: () => true,
}))
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// refreshDocuments — pending / orphan entities
// ---------------------------------------------------------------------------
describe('refreshDocuments', () => {
it('does NOT load documents for a pending piece node (orphan link id is not a piece id)', async () => {
// A category-only / pending piece node: its `id` is the machinePieceLink id,
// there is no real piece behind it (pieceId is null).
const pendingNode = {
id: 'cl48179803369dd93b4a90b784', // machinePieceLink id, NOT a piece id
pieceId: null,
pendingEntity: true,
documents: [],
}
const { refreshDocuments } = useEntityDocuments({
entity: () => pendingNode,
entityType: 'piece',
})
await refreshDocuments()
expect(mockLoadDocumentsByPiece).not.toHaveBeenCalled()
})
it('loads documents for a real piece node using its piece id', async () => {
mockLoadDocumentsByPiece.mockResolvedValue({ success: true, data: [] })
const realNode = {
id: 'clrealpieceid000000000000',
pieceId: 'clrealpieceid000000000000',
pendingEntity: false,
documents: [],
}
const { refreshDocuments } = useEntityDocuments({
entity: () => realNode,
entityType: 'piece',
})
await refreshDocuments()
expect(mockLoadDocumentsByPiece).toHaveBeenCalledWith('clrealpieceid000000000000', { updateStore: false })
})
})
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Repair migration for Version20260528090000_FixPieceCascadeFKs.
*
* On some environments (prod included) that migration was recorded as executed
* but two of its six FKs to `pieces.id` never took effect:
* - machine_piece_links.pieceid (fk_mpl_piece)
* - custom_field_values.pieceid (fk_cfv_piece)
* Without them, deleting a Piece leaves orphan rows behind (a stale pieceid
* pointing to a non-existent piece), which surfaces as a "Catégorie sans item"
* ghost on the machine detail page and a 404 on /documents/piece/{id}.
*
* This migration re-applies ONLY those two missing pieces of the original one:
* snapshot orphans to audit_logs, delete them, then (re)add the FK with the
* correct ON DELETE CASCADE. Fully idempotent — safe where the FKs already exist.
*/
final class Version20260529150000_AddMissingPieceCascadeFKs extends AbstractMigration
{
public function getDescription(): string
{
return 'Repair missing CASCADE FKs to pieces on machine_piece_links and custom_field_values (orphan cleanup + fk_mpl_piece / fk_cfv_piece)';
}
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 repair migration (Version20260529150000) - 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),
'custom_field_value',
v.id,
'delete',
json_build_object(
'id', v.id,
'pieceId', v.pieceid,
'note', 'Cleaned by FK cascade repair migration (Version20260529150000) - 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);
// =========================================================================
// 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'
DELETE FROM custom_field_values
WHERE pieceid IS NOT NULL
AND pieceid 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('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);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_mpl_piece');
$this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_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);
}
}