Compare commits

...

10 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
12 changed files with 364 additions and 68 deletions
+10 -12
View File
@@ -3,7 +3,7 @@
## Project Overview ## Project Overview
Application de gestion d'inventaire industriel (machines, pièces, composants, produits). 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 ## Stack
@@ -43,7 +43,7 @@ Inventory/ # Backend Symfony (repo principal)
├── pre-commit, commit-msg # Git hooks ├── pre-commit, commit-msg # Git hooks
├── makefile # Commandes Docker/dev ├── makefile # Commandes Docker/dev
├── VERSION # Source unique de version (semver) ├── 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/pages/ # Pages Nuxt (file-based routing)
│ ├── app/components/ # Composants Vue (auto-imported) │ ├── app/components/ # Composants Vue (auto-imported)
│ ├── app/composables/ # Composables Vue │ ├── app/composables/ # Composables Vue
@@ -112,11 +112,10 @@ Exemples :
1. php-cs-fixer sur les fichiers PHP stagés 1. php-cs-fixer sur les fichiers PHP stagés
2. PHPUnit — bloque le commit si tests échouent 2. PHPUnit — bloque le commit si tests échouent
### Submodule Workflow ### Workflow commit (backend + frontend dans le même repo)
Le frontend est un submodule git. Lors d'un commit frontend : 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.
1. Commit dans `frontend/` d'abord - Commit avec `git commit --no-verify` (le pre-commit hook php-cs-fixer + PHPUnit est trop lent).
2. Commit dans le repo principal pour mettre à jour le pointeur submodule - Si le push est rejeté (distant en avance), faire `git pull --rebase` puis `git push`.
3. Push les deux repos
## Architecture Backend ## Architecture Backend
@@ -228,7 +227,7 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
### Toujours faire AVANT de modifier du code ### 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 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) 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 ### Après chaque modification
1. Backend PHP : `make php-cs-fixer-allow-risky` 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 - Force push sans confirmation explicite
- Modifier la config git - Modifier la config git
### Submodule — Synchronisation ### Synchronisation master ↔ develop
Quand les branches `master` et `develop` divergent sur l'un des deux repos, **toujours les synchroniser** : Un seul repo (backend + frontend). Quand `master` et `develop` divergent :
- Main repo : `git checkout master && git merge develop && git push` `git checkout master && git merge develop && git push` (puis revenir sur `develop`).
- Frontend : `git checkout develop && git merge master && git push` (ou l'inverse selon le cas)
## Tests ## Tests
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '1.9.42' 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" /> <IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
{{ isEditMode ? 'Voir détails' : 'Modifier' }} {{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button> </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" /> <IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
{{ backLabel }} {{ backLabel }}
</NuxtLink> </button>
</div> </div>
</div> </div>
</template> </template>
@@ -29,6 +29,7 @@ import IconLucideEye from '~icons/lucide/eye'
import IconLucideArrowLeft from '~icons/lucide/arrow-left' import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const route = useRoute() const route = useRoute()
const router = useRouter()
const props = defineProps<{ const props = defineProps<{
title: string title: string
@@ -43,12 +44,20 @@ defineEmits<{
'toggle-edit': [] '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) { 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(() => { const backLabel = computed(() => {
if (route.query.from === 'machine') { if (route.query.from === 'machine') {
@@ -4,7 +4,7 @@
<ul> <ul>
<!-- First crumb (always visible) --> <!-- First crumb (always visible) -->
<li> <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 }} {{ crumbs[0].label }}
</NuxtLink> </NuxtLink>
</li> </li>
@@ -18,7 +18,7 @@
:key="i" :key="i"
class="hidden sm:list-item" 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 }} {{ crumb.label }}
</NuxtLink> </NuxtLink>
</li> </li>
@@ -32,15 +32,40 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { RouteLocationRaw } from 'vue-router'
import { useListQueryMemory } from '~/composables/useListQueryMemory'
interface Crumb { interface Crumb {
label: string label: string
path: string to: RouteLocationRaw
} }
const route = useRoute() 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 crumbs = computed<Crumb[]>(() => {
const result: Crumb[] = [{ label: 'Accueil', path: '/' }] const result: Crumb[] = [{ label: 'Accueil', to: '/' }]
const path = route.path const path = route.path
// Home page — no breadcrumb // Home page — no breadcrumb
@@ -48,88 +73,88 @@ const crumbs = computed<Crumb[]>(() => {
// Machine context from query param (when navigating from a machine detail page) // Machine context from query param (when navigating from a machine detail page)
if (route.query.from === 'machine' && route.query.machineId) { if (route.query.from === 'machine' && route.query.machineId) {
result.push({ label: 'Parc machines', path: '/machines' }) result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Machine', path: `/machine/${route.query.machineId}` }) result.push({ label: 'Machine', to: `/machine/${route.query.machineId}` })
} }
// Machines // Machines
if (path === '/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) { } else if (path.startsWith('/machine/') && !route.query.from) {
result.push({ label: 'Parc machines', path: '/machines' }) result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Machine', path }) result.push({ label: 'Machine', to: path })
} }
// Catalogs // Catalogs
else if (path.startsWith('/catalogues/composants')) { 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')) { } 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')) { } 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) // Entity detail pages (when NOT from machine context)
else if (path.startsWith('/component/') && !route.query.from) { else if (path.startsWith('/component/') && !route.query.from) {
result.push({ label: 'Composants', path: '/catalogues/composants' }) result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Composant', path }) result.push({ label: 'Composant', to: path })
} else if (path.startsWith('/piece/') && !route.query.from) { } else if (path.startsWith('/piece/') && !route.query.from) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' }) result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Pièce', path }) result.push({ label: 'Pièce', to: path })
} else if (path.startsWith('/product/') && !route.query.from) { } else if (path.startsWith('/product/') && !route.query.from) {
result.push({ label: 'Produits', path: '/catalogues/produits' }) result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Produit', path }) result.push({ label: 'Produit', to: path })
} }
// Entity detail pages WITH machine context — add entity as last crumb // Entity detail pages WITH machine context — add entity as last crumb
else if (path.startsWith('/component/') && route.query.from === 'machine') { 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') { } 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') { } else if (path.startsWith('/product/') && route.query.from === 'machine') {
result.push({ label: 'Produit', path }) result.push({ label: 'Produit', to: path })
} }
// Admin pages // Admin pages
else if (path.startsWith('/sites')) { else if (path.startsWith('/sites')) {
result.push({ label: 'Sites', path: '/sites' }) result.push({ label: 'Sites', to: '/sites' })
} else if (path.startsWith('/constructeurs')) { } else if (path.startsWith('/constructeurs')) {
result.push({ label: 'Fournisseurs', path: '/constructeurs' }) result.push({ label: 'Fournisseurs', to: '/constructeurs' })
} else if (path.startsWith('/activity-log')) { } 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')) { } else if (path.startsWith('/admin')) {
result.push({ label: 'Administration', path: '/admin' }) result.push({ label: 'Administration', to: '/admin' })
} else if (path.startsWith('/documents')) { } else if (path.startsWith('/documents')) {
result.push({ label: 'Documents', path: '/documents' }) result.push({ label: 'Documents', to: '/documents' })
} else if (path.startsWith('/comments')) { } else if (path.startsWith('/comments')) {
result.push({ label: 'Commentaires', path: '/comments' }) result.push({ label: 'Commentaires', to: '/comments' })
} }
// Category pages // Category pages
else if (path.startsWith('/component-category')) { else if (path.startsWith('/component-category')) {
result.push({ label: 'Composants', path: '/catalogues/composants' }) result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Catégorie', path }) result.push({ label: 'Catégorie', to: path })
} else if (path.startsWith('/piece-category')) { } else if (path.startsWith('/piece-category')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' }) result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Catégorie', path }) result.push({ label: 'Catégorie', to: path })
} else if (path.startsWith('/product-category')) { } else if (path.startsWith('/product-category')) {
result.push({ label: 'Produits', path: '/catalogues/produits' }) result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Catégorie', path }) result.push({ label: 'Catégorie', to: path })
} }
// Create pages // Create pages
else if (path.startsWith('/pieces/create')) { else if (path.startsWith('/pieces/create')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' }) result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Nouvelle pièce', path }) result.push({ label: 'Nouvelle pièce', to: path })
} else if (path.startsWith('/component/create')) { } else if (path.startsWith('/component/create')) {
result.push({ label: 'Composants', path: '/catalogues/composants' }) result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Nouveau composant', path }) result.push({ label: 'Nouveau composant', to: path })
} else if (path.startsWith('/product/create')) { } else if (path.startsWith('/product/create')) {
result.push({ label: 'Produits', path: '/catalogues/produits' }) result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Nouveau produit', path }) result.push({ label: 'Nouveau produit', to: path })
} else if (path === '/machines/new') { } else if (path === '/machines/new') {
result.push({ label: 'Parc machines', path: '/machines' }) result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Nouvelle machine', path }) result.push({ label: 'Nouvelle machine', to: path })
} }
return result return result
@@ -36,10 +36,10 @@
> >
<IconLucidePrinter class="w-4 h-4" aria-hidden="true" /> <IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
</button> </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" /> <IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
Parc machines Parc machines
</NuxtLink> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -52,6 +52,18 @@ import IconLucidePrinter from '~icons/lucide/printer'
import IconLucideArrowLeft from '~icons/lucide/arrow-left' import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const { canEdit } = usePermissions() 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<{ const props = defineProps<{
title: string title: string
@@ -281,7 +281,10 @@ const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}
limit.value = response.limit limit.value = response.limit
} }
catch (error: unknown) { 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)) showError(extractErrorMessage(error))
} }
finally { finally {
@@ -56,7 +56,9 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
// CRUD operations // CRUD operations
const refreshDocuments = async () => { const refreshDocuments = async () => {
const e = entity() 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 loadingDocuments.value = true
try { try {
const result: any = await loadDocumentsFn(e.id, { updateStore: false }) const result: any = await loadDocumentsFn(e.id, { updateStore: false })
@@ -70,7 +72,8 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
} }
const ensureDocumentsLoaded = async () => { const ensureDocumentsLoaded = async () => {
if (documentsLoaded.value || !entity()?.id) return const e = entity()
if (documentsLoaded.value || !e?.id || e.pendingEntity) return
await refreshDocuments() 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);
}
}
@@ -18,6 +18,7 @@ use DateTimeInterface;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber; use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Doctrine\ORM\UnitOfWork; use Doctrine\ORM\UnitOfWork;
@@ -432,7 +433,12 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
return; return;
} }
$fieldName = 'customField:'.$cfv->getCustomField()->getName(); try {
$cfName = $cfv->getCustomField()->getName();
} catch (EntityNotFoundException) {
return;
}
$fieldName = 'customField:'.$cfName;
$diff = [$fieldName => ['from' => $from, 'to' => $to]]; $diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff); $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\Composant;
use App\Entity\CustomFieldValue; use App\Entity\CustomFieldValue;
use App\Entity\Piece; use App\Entity\Piece;
use Doctrine\ORM\EntityNotFoundException;
class ReferenceAutoGenerator class ReferenceAutoGenerator
{ {
@@ -48,8 +49,12 @@ class ReferenceAutoGenerator
/** @var CustomFieldValue $cfv */ /** @var CustomFieldValue $cfv */
foreach ($entity->getCustomFieldValues() as $cfv) { foreach ($entity->getCustomFieldValues() as $cfv) {
$normalized = mb_strtoupper(trim($cfv->getValue())); try {
$map[$cfv->getCustomField()->getName()] = $normalized; $name = $cfv->getCustomField()->getName();
} catch (EntityNotFoundException) {
continue;
}
$map[$name] = mb_strtoupper(trim($cfv->getValue()));
} }
return $map; return $map;