Compare commits

..

14 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
gitea-actions f18ae545d8 chore : bump version to v1.9.43
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 45s
2026-05-28 15:15:15 +00:00
Matthieu 3003ced157 fix(custom-fields) : protéger les flushs contre les CustomField orphelins
Auto Tag Develop / tag (push) Successful in 10s
Deux endroits accèdent à $cfv->getCustomField()->getName() à chaque flush
touchant un CustomFieldValue. Si la CustomField a été supprimée et que la
FK n'est pas en ON DELETE CASCADE, le proxy lève EntityNotFoundException
et fait crasher tout le flush (pas juste une lecture, comme dans le crash
côté MachineStructureController).

- ReferenceAutoGenerator::buildValueMap() : skip le CFV orphelin (la ref
  auto retombera proprement sur null via le check requiredFields existant).
- AbstractAuditSubscriber::trackCustomFieldValueChange() : skip l'entrée
  d'audit pour ce CFV au lieu de propager l'exception.
2026-05-28 17:15:04 +02:00
gitea-actions 2b318ce5d6 chore : bump version to v1.9.42
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 40s
2026-05-28 15:09:49 +00:00
Matthieu c10ab08803 fix(custom-fields) : forcer initializeObject pour vraiment charger le proxy
Auto Tag Develop / tag (push) Successful in 10s
Le helper ensureCustomFieldExists (commit af13dc0) appelait $cf->getId()
pour déclencher l'init du proxy, mais sur un proxy Doctrine getId() retourne
directement l'identifiant stocké dans le proxy (la clé utilisée pour le
construire) sans appeler __load(). L'EntityNotFoundException n'était donc
jamais levée dans le helper et le crash sortait quand même sur getName()
ligne 973.

Remplacement par EntityManager::initializeObject() qui appelle __load() et
propage bien l'exception. Même correction appliquée à ensurePieceExists()
dans les deux contrôleurs (le bug y était masqué par la migration FK
CASCADE/SET NULL livrée dans le commit 003e419).
2026-05-28 17:09:34 +02:00
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
14 changed files with 420 additions and 79 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.40'
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);
}
}
+28 -7
View File
@@ -298,8 +298,9 @@ class CustomFieldValueController 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 an orphan link to a deleted piece doesn't crash custom-field value writes.
* 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
{
@@ -307,7 +308,7 @@ class CustomFieldValueController extends AbstractController
return null;
}
try {
$piece->getId();
$this->entityManager->initializeObject($piece);
return $piece;
} catch (EntityNotFoundException) {
@@ -315,22 +316,42 @@ class CustomFieldValueController extends AbstractController
}
}
/**
* 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(),
+28 -4
View File
@@ -815,8 +815,9 @@ 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.
* 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
{
@@ -824,7 +825,7 @@ class MachineStructureController extends AbstractController
return null;
}
try {
$piece->getId();
$this->entityManager->initializeObject($piece);
return $piece;
} catch (EntityNotFoundException) {
@@ -832,6 +833,26 @@ class MachineStructureController extends AbstractController
}
}
/**
* 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();
@@ -942,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(),
@@ -18,6 +18,7 @@ use DateTimeInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\UnitOfWork;
@@ -432,7 +433,12 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
return;
}
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
try {
$cfName = $cfv->getCustomField()->getName();
} catch (EntityNotFoundException) {
return;
}
$fieldName = 'customField:'.$cfName;
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
+7 -2
View File
@@ -7,6 +7,7 @@ namespace App\Service;
use App\Entity\Composant;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use Doctrine\ORM\EntityNotFoundException;
class ReferenceAutoGenerator
{
@@ -48,8 +49,12 @@ class ReferenceAutoGenerator
/** @var CustomFieldValue $cfv */
foreach ($entity->getCustomFieldValues() as $cfv) {
$normalized = mb_strtoupper(trim($cfv->getValue()));
$map[$cfv->getCustomField()->getName()] = $normalized;
try {
$name = $cfv->getCustomField()->getName();
} catch (EntityNotFoundException) {
continue;
}
$map[$name] = mb_strtoupper(trim($cfv->getValue()));
}
return $map;