Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e05ba6a97c | |||
| 012d552ddc | |||
| 594ed7b631 | |||
| 7836f87cd2 | |||
| d5361ac3ec | |||
| 477295c400 | |||
| 22dddb73bd | |||
| cb49c69662 | |||
| f18ae545d8 | |||
| 3003ced157 | |||
| 2b318ce5d6 | |||
| c10ab08803 | |||
| 85d4726415 | |||
| af13dc0237 | |||
| 7e2cabfa65 | |||
| 003e419a93 | |||
| d1b170d87f | |||
| 0fc9daa974 | |||
| 104942a52b | |||
| c65757ee24 | |||
| 6e105fd070 | |||
| a0c4597de0 | |||
| d3f269452c | |||
| b3fa927e77 | |||
| f71f4c68da | |||
| 905d5c0957 | |||
| 03a5d05a2c |
@@ -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,7 +1,7 @@
|
|||||||
api_platform:
|
api_platform:
|
||||||
title: Inventory API
|
title: Inventory API
|
||||||
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
||||||
version: 1.9.6
|
version: 1.9.40
|
||||||
defaults:
|
defaults:
|
||||||
stateless: false
|
stateless: false
|
||||||
cache_headers:
|
cache_headers:
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '1.9.33'
|
app.version: '1.9.47'
|
||||||
|
|||||||
@@ -69,9 +69,25 @@
|
|||||||
<span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}€</span>
|
<span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}€</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
|
||||||
|
<div
|
||||||
|
v-if="visibleContextFieldTags.length"
|
||||||
|
class="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="field in visibleContextFieldTags"
|
||||||
|
:key="field.name"
|
||||||
|
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
|
||||||
|
:class="contextFieldBadgeClass(field)"
|
||||||
|
>
|
||||||
|
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
|
||||||
|
<span class="text-sm font-bold">{{ field.value }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Row 2: Metadata tags -->
|
<!-- Row 2: Metadata tags -->
|
||||||
<div
|
<div
|
||||||
v-if="componentConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)"
|
v-if="componentConstructeursDisplay.length || displayProductName"
|
||||||
class="flex flex-wrap items-center gap-1.5"
|
class="flex flex-wrap items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -85,17 +101,6 @@
|
|||||||
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
|
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
|
||||||
{{ displayProductName }}
|
{{ displayProductName }}
|
||||||
</span>
|
</span>
|
||||||
<!-- Context field tags (consultation only) -->
|
|
||||||
<template v-if="!isEditMode">
|
|
||||||
<span
|
|
||||||
v-for="field in visibleContextFieldTags"
|
|
||||||
:key="field.name"
|
|
||||||
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
|
|
||||||
:class="contextFieldBadgeClass(field)"
|
|
||||||
>
|
|
||||||
{{ field.name }} : {{ field.value }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -71,9 +71,25 @@
|
|||||||
<span v-if="pieceData.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ pieceData.prix }}€</span>
|
<span v-if="pieceData.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ pieceData.prix }}€</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
|
||||||
|
<div
|
||||||
|
v-if="visibleContextFieldTags.length"
|
||||||
|
class="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="field in visibleContextFieldTags"
|
||||||
|
:key="field.name"
|
||||||
|
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
|
||||||
|
:class="contextFieldBadgeClass(field)"
|
||||||
|
>
|
||||||
|
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
|
||||||
|
<span class="text-sm font-bold">{{ field.value }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Row 2: Metadata tags -->
|
<!-- Row 2: Metadata tags -->
|
||||||
<div
|
<div
|
||||||
v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)"
|
v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName"
|
||||||
class="flex flex-wrap items-center gap-1.5"
|
class="flex flex-wrap items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<span v-if="piece.parentComponentName" class="text-[0.65rem] text-base-content/40 bg-base-300/20 px-1.5 py-0.5 rounded">
|
<span v-if="piece.parentComponentName" class="text-[0.65rem] text-base-content/40 bg-base-300/20 px-1.5 py-0.5 rounded">
|
||||||
@@ -90,17 +106,6 @@
|
|||||||
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
|
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
|
||||||
{{ displayProductName }}
|
{{ displayProductName }}
|
||||||
</span>
|
</span>
|
||||||
<!-- Context field tags (consultation only) -->
|
|
||||||
<template v-if="!isEditMode">
|
|
||||||
<span
|
|
||||||
v-for="field in visibleContextFieldTags"
|
|
||||||
:key="field.name"
|
|
||||||
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
|
|
||||||
:class="contextFieldBadgeClass(field)"
|
|
||||||
>
|
|
||||||
{{ field.name }} : {{ field.value }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,19 @@
|
|||||||
Ajouter une nouvelle machine
|
Ajouter une nouvelle machine
|
||||||
</h3>
|
</h3>
|
||||||
<form @submit.prevent="handleSubmit">
|
<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="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -78,6 +91,7 @@ const props = defineProps<{
|
|||||||
sites: Array<{ id: string, name: string }>
|
sites: Array<{ id: string, name: string }>
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
preselectedSiteId?: string
|
preselectedSiteId?: string
|
||||||
|
errorMessage?: string | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useApi } from './useApi'
|
import { useApi } from './useApi'
|
||||||
import { useToast } from './useToast'
|
import { useToast } from './useToast'
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
export interface ConstructeurTelephone {
|
export interface ConstructeurTelephone {
|
||||||
'@id'?: string
|
'@id'?: string
|
||||||
@@ -33,6 +33,24 @@ interface ConstructeurResult {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConstructeurPageOptions {
|
||||||
|
page?: number
|
||||||
|
itemsPerPage?: number
|
||||||
|
search?: string
|
||||||
|
categoryId?: string
|
||||||
|
orderField?: 'name' | 'email' | 'createdAt'
|
||||||
|
orderDirection?: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConstructeurPageResult {
|
||||||
|
success: boolean
|
||||||
|
items: Constructeur[]
|
||||||
|
totalItems: number
|
||||||
|
totalPages: number
|
||||||
|
currentPage: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
const constructeurs = ref<Constructeur[]>([])
|
const constructeurs = ref<Constructeur[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loaded = ref(false)
|
const loaded = ref(false)
|
||||||
@@ -83,8 +101,10 @@ export function useConstructeurs() {
|
|||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const query = search ? `?search=${encodeURIComponent(search)}` : ''
|
const params = new URLSearchParams()
|
||||||
const result = await get(`/constructeurs${query}`)
|
params.set('itemsPerPage', '2000')
|
||||||
|
if (search) params.set('search', search)
|
||||||
|
const result = await get(`/constructeurs?${params.toString()}`)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const items = extractCollection(result.data)
|
const items = extractCollection(result.data)
|
||||||
constructeurs.value = uniqueConstructeurs(items)
|
constructeurs.value = uniqueConstructeurs(items)
|
||||||
@@ -104,6 +124,37 @@ export function useConstructeurs() {
|
|||||||
return loadConstructeurs(search)
|
return loadConstructeurs(search)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchConstructeursPage = async (opts: ConstructeurPageOptions = {}): Promise<ConstructeurPageResult> => {
|
||||||
|
const page = Math.max(1, opts.page ?? 1)
|
||||||
|
const itemsPerPage = Math.max(1, opts.itemsPerPage ?? 30)
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('page', String(page))
|
||||||
|
params.set('itemsPerPage', String(itemsPerPage))
|
||||||
|
if (opts.search && opts.search.trim()) params.set('search', opts.search.trim())
|
||||||
|
if (opts.categoryId) params.set('categories.id', opts.categoryId)
|
||||||
|
if (opts.orderField) {
|
||||||
|
params.set(`order[${opts.orderField}]`, opts.orderDirection ?? 'asc')
|
||||||
|
}
|
||||||
|
const result = await get(`/constructeurs?${params.toString()}`)
|
||||||
|
if (!result.success) {
|
||||||
|
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: result.error }
|
||||||
|
}
|
||||||
|
const items = extractCollection<Constructeur>(result.data)
|
||||||
|
const totalItems = extractTotal(result.data, items.length)
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
|
||||||
|
upsertConstructeurs(items)
|
||||||
|
return { success: true, items, totalItems, totalPages, currentPage: page }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
console.error('Erreur lors du chargement de la page fournisseurs:', error)
|
||||||
|
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => {
|
const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -227,6 +278,7 @@ export function useConstructeurs() {
|
|||||||
loading,
|
loading,
|
||||||
loadConstructeurs,
|
loadConstructeurs,
|
||||||
searchConstructeurs,
|
searchConstructeurs,
|
||||||
|
fetchConstructeursPage,
|
||||||
createConstructeur,
|
createConstructeur,
|
||||||
updateConstructeur,
|
updateConstructeur,
|
||||||
deleteConstructeur,
|
deleteConstructeur,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useMachines } from '~/composables/useMachines'
|
import { useMachines } from '~/composables/useMachines'
|
||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
import { useToast } from '~/composables/useToast'
|
|
||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
|
|
||||||
export function useMachineCreatePage() {
|
export function useMachineCreatePage() {
|
||||||
@@ -18,7 +17,6 @@ export function useMachineCreatePage() {
|
|||||||
|
|
||||||
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
|
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
|
||||||
const { sites, loadSites } = useSites()
|
const { sites, loadSites } = useSites()
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Local state
|
// Local state
|
||||||
@@ -27,6 +25,9 @@ export function useMachineCreatePage() {
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const loading = ref(true)
|
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({
|
const newMachine = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
siteId: '',
|
siteId: '',
|
||||||
@@ -41,8 +42,10 @@ export function useMachineCreatePage() {
|
|||||||
const finalizeMachineCreation = async () => {
|
const finalizeMachineCreation = async () => {
|
||||||
if (submitting.value) return
|
if (submitting.value) return
|
||||||
|
|
||||||
|
createError.value = null
|
||||||
|
|
||||||
if (!newMachine.name?.trim()) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +83,10 @@ export function useMachineCreatePage() {
|
|||||||
await navigateTo('/machines')
|
await navigateTo('/machines')
|
||||||
}
|
}
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
|
createError.value = humanizeError(result.error)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
|
createError.value = humanizeError(error.message)
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -116,6 +119,7 @@ export function useMachineCreatePage() {
|
|||||||
machines,
|
machines,
|
||||||
submitting,
|
submitting,
|
||||||
loading,
|
loading,
|
||||||
|
createError,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
finalizeMachineCreation,
|
finalizeMachineCreation,
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ import { usePieces } from '~/composables/usePieces'
|
|||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { useDataTable } from '~/composables/useDataTable'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
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 { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
|
||||||
import { formatFrenchDate } from '~/utils/date'
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
|
|
||||||
@@ -249,10 +249,25 @@ const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
|
|||||||
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
|
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
const handleDeletePiece = async (piece: Record<string, any>) => {
|
const handleDeletePiece = async (piece: Record<string, any>) => {
|
||||||
const pieceName = piece?.name || 'cette pièce'
|
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 })
|
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
await deletePiece(piece.id)
|
await deletePiece(piece.id)
|
||||||
|
|||||||
@@ -19,13 +19,17 @@
|
|||||||
<div class="card-body space-y-4">
|
<div class="card-body space-y-4">
|
||||||
<DataTable
|
<DataTable
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:rows="filteredConstructeurs"
|
:rows="pageItems"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:sort="currentSort"
|
:sort="currentSort"
|
||||||
:show-counter="false"
|
:pagination="paginationState"
|
||||||
|
:show-counter="true"
|
||||||
|
:show-per-page="true"
|
||||||
empty-message="Aucun fournisseur trouvé."
|
empty-message="Aucun fournisseur trouvé."
|
||||||
no-results-message="Aucun fournisseur trouvé."
|
no-results-message="Aucun fournisseur trouvé."
|
||||||
@sort="handleSort"
|
@sort="handleSort"
|
||||||
|
@update:current-page="onPageChange"
|
||||||
|
@update:per-page="onPerPageChange"
|
||||||
>
|
>
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<div class="flex flex-col sm:flex-row gap-3 w-full">
|
<div class="flex flex-col sm:flex-row gap-3 w-full">
|
||||||
@@ -204,7 +208,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import DataTable from '~/components/common/DataTable.vue'
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||||
@@ -229,10 +233,17 @@ interface ConstructeurFormState {
|
|||||||
|
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
const { constructeurs, loading, createConstructeur, updateConstructeur, deleteConstructeur, fetchConstructeursPage } = useConstructeurs()
|
||||||
const { categories: allCategories, loadCategories } = useConstructeurCategories()
|
const { categories: allCategories, loadCategories } = useConstructeurCategories()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
|
|
||||||
|
const pageItems = ref<typeof constructeurs.value>([])
|
||||||
|
const totalItems = ref(0)
|
||||||
|
const totalPages = ref(0)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const perPage = ref(30)
|
||||||
|
const perPageOptions = [15, 30, 50, 100]
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'name', label: 'Nom', sortable: true },
|
{ key: 'name', label: 'Nom', sortable: true },
|
||||||
{ key: 'email', label: 'Email', sortable: true },
|
{ key: 'email', label: 'Email', sortable: true },
|
||||||
@@ -261,6 +272,44 @@ const handleSort = (sort) => {
|
|||||||
sortDir.value = sort.direction
|
sortDir.value = sort.direction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paginationState = computed(() => ({
|
||||||
|
currentPage: currentPage.value,
|
||||||
|
totalPages: totalPages.value,
|
||||||
|
totalItems: totalItems.value,
|
||||||
|
pageItems: pageItems.value.length,
|
||||||
|
perPage: perPage.value,
|
||||||
|
perPageOptions,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const SORTABLE_FIELDS = new Set(['name', 'email', 'createdAt'])
|
||||||
|
|
||||||
|
const loadPage = async () => {
|
||||||
|
const orderField = SORTABLE_FIELDS.has(sortKey.value)
|
||||||
|
? (sortKey.value as 'name' | 'email' | 'createdAt')
|
||||||
|
: 'name'
|
||||||
|
const result = await fetchConstructeursPage({
|
||||||
|
page: currentPage.value,
|
||||||
|
itemsPerPage: perPage.value,
|
||||||
|
search: searchTerm.value,
|
||||||
|
categoryId: selectedCategoryId.value || undefined,
|
||||||
|
orderField,
|
||||||
|
orderDirection: sortDir.value === 'desc' ? 'desc' : 'asc',
|
||||||
|
})
|
||||||
|
if (!result.success) {
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
pageItems.value = []
|
||||||
|
totalItems.value = 0
|
||||||
|
totalPages.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageItems.value = result.items
|
||||||
|
totalItems.value = result.totalItems
|
||||||
|
totalPages.value = result.totalPages
|
||||||
|
if (currentPage.value > result.totalPages && result.totalPages > 0) {
|
||||||
|
currentPage.value = result.totalPages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const modalOpen = ref(false)
|
const modalOpen = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const editingConstructeur = ref<Record<string, any> | null>(null)
|
const editingConstructeur = ref<Record<string, any> | null>(null)
|
||||||
@@ -268,29 +317,31 @@ const form = ref<ConstructeurFormState>({ name: '', email: '', telephones: [], c
|
|||||||
|
|
||||||
const rowPhones = constructeurPhones
|
const rowPhones = constructeurPhones
|
||||||
|
|
||||||
const filteredConstructeurs = computed(() => {
|
const debouncedSearch = debounce(() => {
|
||||||
const key = sortKey.value
|
currentPage.value = 1
|
||||||
const dir = sortDir.value === 'desc' ? -1 : 1
|
loadPage()
|
||||||
let sorted = [...constructeurs.value].sort((a, b) => {
|
}, 300)
|
||||||
if (key === 'createdAt') {
|
|
||||||
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
|
watch(selectedCategoryId, () => {
|
||||||
}
|
currentPage.value = 1
|
||||||
return dir * (a[key] || '').localeCompare(b[key] || '')
|
loadPage()
|
||||||
})
|
|
||||||
if (selectedCategoryId.value) {
|
|
||||||
sorted = sorted.filter(item => (item.categories || []).some(cat => cat.id === selectedCategoryId.value))
|
|
||||||
}
|
|
||||||
if (!searchTerm.value) { return sorted }
|
|
||||||
const term = searchTerm.value.toLowerCase()
|
|
||||||
return sorted.filter((item) => {
|
|
||||||
const haystack = [item.name, item.email, ...rowPhones(item).map(t => t.numero)]
|
|
||||||
return haystack.some(value => value && String(value).toLowerCase().includes(term))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const debouncedSearch = debounce(async () => {
|
watch([sortKey, sortDir], () => {
|
||||||
await searchConstructeurs(searchTerm.value)
|
currentPage.value = 1
|
||||||
}, 300)
|
loadPage()
|
||||||
|
})
|
||||||
|
|
||||||
|
const onPageChange = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
loadPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPerPageChange = (value: number) => {
|
||||||
|
perPage.value = value
|
||||||
|
currentPage.value = 1
|
||||||
|
loadPage()
|
||||||
|
}
|
||||||
|
|
||||||
const formatDate = formatFrenchDate
|
const formatDate = formatFrenchDate
|
||||||
|
|
||||||
@@ -386,7 +437,7 @@ const saveConstructeur = async () => {
|
|||||||
saving.value = false
|
saving.value = false
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
closeModal()
|
closeModal()
|
||||||
await searchConstructeurs(searchTerm.value)
|
await loadPage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,6 +448,10 @@ const confirmDelete = async (constructeur) => {
|
|||||||
const result = await deleteConstructeur(constructeur.id)
|
const result = await deleteConstructeur(constructeur.id)
|
||||||
if (!result.success && result.error) {
|
if (!result.success && result.error) {
|
||||||
showError(result.error)
|
showError(result.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result.success) {
|
||||||
|
await loadPage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +463,7 @@ const loadStats = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadConstructeurs()
|
loadPage()
|
||||||
loadCategories()
|
loadCategories()
|
||||||
loadStats()
|
loadStats()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -58,7 +58,26 @@
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<label class="label">
|
||||||
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span>
|
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -66,13 +85,13 @@
|
|||||||
<input
|
<input
|
||||||
v-model="dateFrom"
|
v-model="dateFrom"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered w-full"
|
||||||
>
|
>
|
||||||
<span class="text-xs text-base-content/50">à</span>
|
<span class="text-xs text-base-content/50">à</span>
|
||||||
<input
|
<input
|
||||||
v-model="dateTo"
|
v-model="dateTo"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm"
|
class="input input-bordered w-full"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,7 +116,7 @@
|
|||||||
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
|
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
|
||||||
Ajouter un site
|
Ajouter un site
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-sm" @click="showAddMachineModal = true">
|
<button class="btn btn-ghost btn-sm" @click="openAddMachineModal">
|
||||||
Ajouter une machine
|
Ajouter une machine
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +282,8 @@
|
|||||||
:sites="sites"
|
:sites="sites"
|
||||||
:disabled="!canEdit"
|
:disabled="!canEdit"
|
||||||
:preselected-site-id="preselectedSiteId"
|
:preselected-site-id="preselectedSiteId"
|
||||||
@close="showAddMachineModal = false"
|
:error-message="addMachineError"
|
||||||
|
@close="closeAddMachineModal"
|
||||||
@create="handleCreateMachine"
|
@create="handleCreateMachine"
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
@@ -293,8 +313,10 @@ const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
|
|||||||
// Data
|
// Data
|
||||||
const showAddSiteModal = ref(false)
|
const showAddSiteModal = ref(false)
|
||||||
const showAddMachineModal = ref(false)
|
const showAddMachineModal = ref(false)
|
||||||
|
const addMachineError = ref(null)
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const selectedSiteFilter = ref('')
|
const selectedSiteFilter = ref('')
|
||||||
|
const sortOrder = ref('name-asc')
|
||||||
const dateFrom = ref('')
|
const dateFrom = ref('')
|
||||||
const dateTo = ref('')
|
const dateTo = ref('')
|
||||||
const collapsedSites = ref([])
|
const collapsedSites = ref([])
|
||||||
@@ -318,10 +340,33 @@ const machinesBySiteId = computed(() => {
|
|||||||
return map
|
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(() => {
|
const sitesWithMachines = computed(() => {
|
||||||
return sites.value.map((site) => ({
|
return sites.value.map((site) => ({
|
||||||
...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) => {
|
const handleCreateMachine = async (data) => {
|
||||||
|
addMachineError.value = null
|
||||||
const result = await createMachine(data)
|
const result = await createMachine(data)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showAddMachineModal.value = false
|
showAddMachineModal.value = false
|
||||||
await loadMachines()
|
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) => {
|
const addMachineToSite = (site) => {
|
||||||
preselectedSiteId.value = site.id
|
preselectedSiteId.value = site.id
|
||||||
showAddMachineModal.value = true
|
openAddMachineModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
|
|||||||
@@ -20,6 +20,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
|
<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 bg-base-100 shadow-sm">
|
||||||
<div class="card-body space-y-6">
|
<div class="card-body space-y-6">
|
||||||
<!-- Basic fields -->
|
<!-- Basic fields -->
|
||||||
|
|||||||
@@ -17,3 +17,74 @@ export const buildDeleteMessage = (entityName: string, impacts: string[]): strin
|
|||||||
lines.push('Cette action est irréversible.')
|
lines.push('Cette action est irréversible.')
|
||||||
return lines.join('\n\n')
|
return lines.join('\n\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UsedInMachine {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
site?: { id: string; name: string | null } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsedInEntity {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageInfo {
|
||||||
|
machines?: UsedInMachine[]
|
||||||
|
composants?: UsedInEntity[]
|
||||||
|
pieces?: UsedInEntity[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMachineLine = (m: UsedInMachine): string => {
|
||||||
|
const name = m.name?.trim() || '(sans nom)'
|
||||||
|
const siteName = m.site?.name?.trim()
|
||||||
|
return siteName ? `${name} (${siteName})` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a delete-confirmation message that lists the machines (and other
|
||||||
|
* entities) currently using the item. The user sees exactly what will be
|
||||||
|
* detached before they confirm the deletion.
|
||||||
|
*/
|
||||||
|
export const buildDeleteMessageWithUsage = (
|
||||||
|
entityName: string,
|
||||||
|
entityLabel: string,
|
||||||
|
usage: UsageInfo,
|
||||||
|
): string => {
|
||||||
|
const machines = usage.machines ?? []
|
||||||
|
const composants = usage.composants ?? []
|
||||||
|
const pieces = usage.pieces ?? []
|
||||||
|
|
||||||
|
const lines = [`Voulez-vous vraiment supprimer « ${entityName} » ?`]
|
||||||
|
|
||||||
|
if (machines.length > 0) {
|
||||||
|
const header = machines.length === 1
|
||||||
|
? `${entityLabel} est actuellement utilisée par 1 machine :`
|
||||||
|
: `${entityLabel} est actuellement utilisée par ${machines.length} machines :`
|
||||||
|
const bullets = machines.map((m) => `• ${formatMachineLine(m)}`).join('\n')
|
||||||
|
lines.push(`${header}\n${bullets}\n\nLa supprimer la retirera de ${machines.length === 1 ? 'cette machine' : 'ces machines'}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (composants.length > 0) {
|
||||||
|
const header = composants.length === 1
|
||||||
|
? 'Elle est également référencée par 1 composant :'
|
||||||
|
: `Elle est également référencée par ${composants.length} composants :`
|
||||||
|
const bullets = composants
|
||||||
|
.map((c) => `• ${c.name?.trim() || '(sans nom)'}`)
|
||||||
|
.join('\n')
|
||||||
|
lines.push(`${header}\n${bullets}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pieces.length > 0) {
|
||||||
|
const header = pieces.length === 1
|
||||||
|
? 'Elle est également utilisée par 1 pièce :'
|
||||||
|
: `Elle est également utilisée par ${pieces.length} pièces :`
|
||||||
|
const bullets = pieces
|
||||||
|
.map((p) => `• ${p.name?.trim() || '(sans nom)'}`)
|
||||||
|
.join('\n')
|
||||||
|
lines.push(`${header}\n${bullets}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('Cette action est irréversible.')
|
||||||
|
return lines.join('\n\n')
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260527140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Machine name uniqueness is now scoped per site: drop global unique index on machines(name), add composite unique index on (name, siteid)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Drop the global unique index/constraint on machines(name).
|
||||||
|
// Doctrine-generated name (CRC32 of table+column): uniq_f1ce8ded5e237e06.
|
||||||
|
// It may exist either as a constraint or as a bare index depending on origin,
|
||||||
|
// so we drop defensively in both forms.
|
||||||
|
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS uniq_f1ce8ded5e237e06');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS uniq_f1ce8ded5e237e06');
|
||||||
|
// Defensive fallbacks for other possible legacy names of the global unique index on name.
|
||||||
|
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS machines_name_key');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS machines_name_key');
|
||||||
|
|
||||||
|
// New uniqueness scope: a machine name is unique within a given site only.
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_machine_name_site ON machines (name, siteid)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS uniq_machine_name_site');
|
||||||
|
|
||||||
|
// Best-effort restore of the global unique index on machines(name).
|
||||||
|
// WARNING: this will fail if duplicate names now exist across sites (which the
|
||||||
|
// per-site scope allowed). Resolve duplicates manually before rolling back.
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_f1ce8ded5e237e06 ON machines (name)');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Align all FKs pointing to `pieces.id` with what entities declare
|
||||||
|
* (ON DELETE CASCADE / SET NULL). Cleans up pre-existing orphan rows
|
||||||
|
* inserted before the constraints existed, so the new FKs can be added.
|
||||||
|
*
|
||||||
|
* Mirror of Version20260506140000_FixComposantCascadeFKs for the Piece side.
|
||||||
|
*/
|
||||||
|
final class Version20260528090000_FixPieceCascadeFKs extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Align CASCADE/SET NULL FKs on pieces references (machine_piece_links, composant_piece_slots, piece_product_slots, documents, custom_field_values, piece_constructeur_links); cleanup pre-existing orphans';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// =========================================================================
|
||||||
|
// 1. Audit log : snapshot des rows orphelines avant suppression.
|
||||||
|
// =========================================================================
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'machine_piece_link',
|
||||||
|
l.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', l.id,
|
||||||
|
'machineId', l.machineid,
|
||||||
|
'pieceId', l.pieceid,
|
||||||
|
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM machine_piece_links l
|
||||||
|
WHERE l.pieceid IS NOT NULL
|
||||||
|
AND l.pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'piece_product_slot',
|
||||||
|
s.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', s.id,
|
||||||
|
'pieceId', s.pieceid,
|
||||||
|
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM piece_product_slots s
|
||||||
|
WHERE s.pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'document',
|
||||||
|
d.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', d.id,
|
||||||
|
'name', d.name,
|
||||||
|
'filename', d.filename,
|
||||||
|
'pieceId', d.pieceid,
|
||||||
|
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM documents d
|
||||||
|
WHERE d.pieceid IS NOT NULL
|
||||||
|
AND d.pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'custom_field_value',
|
||||||
|
v.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', v.id,
|
||||||
|
'pieceId', v.pieceid,
|
||||||
|
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM custom_field_values v
|
||||||
|
WHERE v.pieceid IS NOT NULL
|
||||||
|
AND v.pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'piece_constructeur_link',
|
||||||
|
l.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', l.id,
|
||||||
|
'pieceId', l.pieceid,
|
||||||
|
'constructeurId', l.constructeurid,
|
||||||
|
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM piece_constructeur_links l
|
||||||
|
WHERE l.pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. Nettoyage des orphelins (avant ADD CONSTRAINT, sinon PG rejette).
|
||||||
|
// =========================================================================
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM machine_piece_links
|
||||||
|
WHERE pieceid IS NOT NULL
|
||||||
|
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
UPDATE composant_piece_slots SET selectedpieceid = NULL
|
||||||
|
WHERE selectedpieceid IS NOT NULL
|
||||||
|
AND selectedpieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM piece_product_slots
|
||||||
|
WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM documents
|
||||||
|
WHERE pieceid IS NOT NULL
|
||||||
|
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM custom_field_values
|
||||||
|
WHERE pieceid IS NOT NULL
|
||||||
|
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM piece_constructeur_links
|
||||||
|
WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM piece_products
|
||||||
|
WHERE piece_id NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 3. Drop des éventuelles FK existantes vers `pieces` (quel que soit leur
|
||||||
|
// nom historique), puis ADD CONSTRAINT avec le bon ON DELETE.
|
||||||
|
// =========================================================================
|
||||||
|
$this->dropFksReferencingPieces('machine_piece_links', 'pieceid');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE machine_piece_links ADD CONSTRAINT fk_mpl_piece
|
||||||
|
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->dropFksReferencingPieces('composant_piece_slots', 'selectedpieceid');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE composant_piece_slots ADD CONSTRAINT fk_cps_selected_piece
|
||||||
|
FOREIGN KEY (selectedpieceid) REFERENCES pieces(id) ON DELETE SET NULL
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->dropFksReferencingPieces('piece_product_slots', 'pieceid');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE piece_product_slots ADD CONSTRAINT fk_pps_piece
|
||||||
|
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->dropFksReferencingPieces('documents', 'pieceid');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE documents ADD CONSTRAINT fk_documents_piece
|
||||||
|
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->dropFksReferencingPieces('custom_field_values', 'pieceid');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_piece
|
||||||
|
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->dropFksReferencingPieces('piece_constructeur_links', 'pieceid');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE piece_constructeur_links ADD CONSTRAINT fk_pcl_piece
|
||||||
|
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_mpl_piece');
|
||||||
|
$this->addSql('ALTER TABLE composant_piece_slots DROP CONSTRAINT IF EXISTS fk_cps_selected_piece');
|
||||||
|
$this->addSql('ALTER TABLE piece_product_slots DROP CONSTRAINT IF EXISTS fk_pps_piece');
|
||||||
|
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_piece');
|
||||||
|
$this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_piece');
|
||||||
|
$this->addSql('ALTER TABLE piece_constructeur_links DROP CONSTRAINT IF EXISTS fk_pcl_piece');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop every FK on $table.$column that references the `pieces` table,
|
||||||
|
* regardless of its historic name. Idempotent.
|
||||||
|
*/
|
||||||
|
private function dropFksReferencingPieces(string $table, string $column): void
|
||||||
|
{
|
||||||
|
$sql = <<<SQL
|
||||||
|
DO \$\$
|
||||||
|
DECLARE
|
||||||
|
fk_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR fk_name IN
|
||||||
|
SELECT tc.constraint_name
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON kcu.constraint_name = tc.constraint_name
|
||||||
|
AND kcu.table_schema = tc.table_schema
|
||||||
|
JOIN information_schema.constraint_column_usage ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name
|
||||||
|
AND ccu.table_schema = tc.table_schema
|
||||||
|
WHERE tc.table_name = '{$table}'
|
||||||
|
AND tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND kcu.column_name = '{$column}'
|
||||||
|
AND ccu.table_name = 'pieces'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE {$table} DROP CONSTRAINT %I', fk_name);
|
||||||
|
END LOOP;
|
||||||
|
END \$\$;
|
||||||
|
SQL;
|
||||||
|
$this->addSql($sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- cleanup_orphan_piece_refs.sql
|
||||||
|
-- =============================================================================
|
||||||
|
-- Contexte : la suppression directe de rows dans `pieces` (bypass Doctrine /
|
||||||
|
-- FK DB sans ON DELETE CASCADE) laisse des références orphelines dans plusieurs
|
||||||
|
-- tables, ce qui fait planter l'API au chargement d'une Machine :
|
||||||
|
-- Doctrine\ORM\EntityNotFoundException: Entity of type 'App\Entity\Piece' ...
|
||||||
|
--
|
||||||
|
-- Ce script fait deux choses :
|
||||||
|
-- 1. ÉTAPE 1 (toujours exécutée) : compte les références orphelines par table
|
||||||
|
-- pour visualiser l'ampleur du problème.
|
||||||
|
-- 2. ÉTAPE 2 (commentée par défaut) : insère un audit_log par row, puis
|
||||||
|
-- DELETE / UPDATE SET NULL selon la sémantique attendue côté entité.
|
||||||
|
-- Décommenter le bloc `BEGIN; ... COMMIT;` pour appliquer.
|
||||||
|
--
|
||||||
|
-- Usage :
|
||||||
|
-- # Dry-run (compte seulement)
|
||||||
|
-- psql -h <host> -U <user> -d inventory -f scripts/cleanup_orphan_piece_refs.sql
|
||||||
|
--
|
||||||
|
-- # Application : décommenter le bloc transactionnel en bas du fichier,
|
||||||
|
-- # puis relancer la même commande. La transaction garantit l'atomicité.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================== ÉTAPE 1 : DRY-RUN ============================
|
||||||
|
\echo ''
|
||||||
|
\echo '=== Orphelins par table (Pieces) ==='
|
||||||
|
|
||||||
|
SELECT 'machine_piece_links' AS table_name, count(*) AS orphans
|
||||||
|
FROM machine_piece_links
|
||||||
|
WHERE pieceid IS NOT NULL
|
||||||
|
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'composant_piece_slots', count(*)
|
||||||
|
FROM composant_piece_slots
|
||||||
|
WHERE selectedpieceid IS NOT NULL
|
||||||
|
AND selectedpieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'piece_product_slots', count(*)
|
||||||
|
FROM piece_product_slots
|
||||||
|
WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'documents', count(*)
|
||||||
|
FROM documents
|
||||||
|
WHERE pieceid IS NOT NULL
|
||||||
|
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'custom_field_values', count(*)
|
||||||
|
FROM custom_field_values
|
||||||
|
WHERE pieceid IS NOT NULL
|
||||||
|
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'piece_constructeur_links', count(*)
|
||||||
|
FROM piece_constructeur_links
|
||||||
|
WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'piece_products', count(*)
|
||||||
|
FROM piece_products
|
||||||
|
WHERE piece_id NOT IN (SELECT id FROM pieces)
|
||||||
|
ORDER BY table_name;
|
||||||
|
|
||||||
|
\echo ''
|
||||||
|
\echo '=> Pour appliquer le cleanup, décommenter le bloc BEGIN/COMMIT ci-dessous.'
|
||||||
|
\echo ''
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================== ÉTAPE 2 : APPLY =============================
|
||||||
|
-- Décommenter ce bloc pour exécuter le cleanup. La transaction garantit
|
||||||
|
-- l'atomicité : tout passe, ou rien (en cas d'erreur, ROLLBACK auto).
|
||||||
|
--
|
||||||
|
-- BEGIN;
|
||||||
|
--
|
||||||
|
-- -- 1. Audit log : snapshot des rows qui vont être supprimées (traçabilité prod).
|
||||||
|
--
|
||||||
|
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
-- SELECT
|
||||||
|
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
-- 'machine_piece_link',
|
||||||
|
-- l.id,
|
||||||
|
-- 'delete',
|
||||||
|
-- json_build_object(
|
||||||
|
-- 'id', l.id,
|
||||||
|
-- 'machineId', l.machineid,
|
||||||
|
-- 'pieceId', l.pieceid,
|
||||||
|
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||||
|
-- ),
|
||||||
|
-- NULL,
|
||||||
|
-- NOW()
|
||||||
|
-- FROM machine_piece_links l
|
||||||
|
-- WHERE l.pieceid IS NOT NULL
|
||||||
|
-- AND l.pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
-- SELECT
|
||||||
|
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
-- 'piece_product_slot',
|
||||||
|
-- s.id,
|
||||||
|
-- 'delete',
|
||||||
|
-- json_build_object(
|
||||||
|
-- 'id', s.id,
|
||||||
|
-- 'pieceId', s.pieceid,
|
||||||
|
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||||
|
-- ),
|
||||||
|
-- NULL,
|
||||||
|
-- NOW()
|
||||||
|
-- FROM piece_product_slots s
|
||||||
|
-- WHERE s.pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
-- SELECT
|
||||||
|
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
-- 'document',
|
||||||
|
-- d.id,
|
||||||
|
-- 'delete',
|
||||||
|
-- json_build_object(
|
||||||
|
-- 'id', d.id,
|
||||||
|
-- 'name', d.name,
|
||||||
|
-- 'filename', d.filename,
|
||||||
|
-- 'pieceId', d.pieceid,
|
||||||
|
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||||
|
-- ),
|
||||||
|
-- NULL,
|
||||||
|
-- NOW()
|
||||||
|
-- FROM documents d
|
||||||
|
-- WHERE d.pieceid IS NOT NULL
|
||||||
|
-- AND d.pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
-- SELECT
|
||||||
|
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
-- 'custom_field_value',
|
||||||
|
-- v.id,
|
||||||
|
-- 'delete',
|
||||||
|
-- json_build_object(
|
||||||
|
-- 'id', v.id,
|
||||||
|
-- 'pieceId', v.pieceid,
|
||||||
|
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||||
|
-- ),
|
||||||
|
-- NULL,
|
||||||
|
-- NOW()
|
||||||
|
-- FROM custom_field_values v
|
||||||
|
-- WHERE v.pieceid IS NOT NULL
|
||||||
|
-- AND v.pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
-- SELECT
|
||||||
|
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
-- 'piece_constructeur_link',
|
||||||
|
-- l.id,
|
||||||
|
-- 'delete',
|
||||||
|
-- json_build_object(
|
||||||
|
-- 'id', l.id,
|
||||||
|
-- 'pieceId', l.pieceid,
|
||||||
|
-- 'constructeurId', l.constructeurid,
|
||||||
|
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||||
|
-- ),
|
||||||
|
-- NULL,
|
||||||
|
-- NOW()
|
||||||
|
-- FROM piece_constructeur_links l
|
||||||
|
-- WHERE l.pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- -- 2. Nettoyage des orphelins.
|
||||||
|
--
|
||||||
|
-- DELETE FROM machine_piece_links
|
||||||
|
-- WHERE pieceid IS NOT NULL
|
||||||
|
-- AND pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- UPDATE composant_piece_slots SET selectedpieceid = NULL
|
||||||
|
-- WHERE selectedpieceid IS NOT NULL
|
||||||
|
-- AND selectedpieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- DELETE FROM piece_product_slots
|
||||||
|
-- WHERE pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- DELETE FROM documents
|
||||||
|
-- WHERE pieceid IS NOT NULL
|
||||||
|
-- AND pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- DELETE FROM custom_field_values
|
||||||
|
-- WHERE pieceid IS NOT NULL
|
||||||
|
-- AND pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- DELETE FROM piece_constructeur_links
|
||||||
|
-- WHERE pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- DELETE FROM piece_products
|
||||||
|
-- WHERE piece_id NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- -- 3. Vérification post-cleanup : tout doit être à 0.
|
||||||
|
-- SELECT 'machine_piece_links' AS table_name, count(*) AS remaining_orphans
|
||||||
|
-- FROM machine_piece_links
|
||||||
|
-- WHERE pieceid IS NOT NULL
|
||||||
|
-- AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT 'composant_piece_slots', count(*)
|
||||||
|
-- FROM composant_piece_slots
|
||||||
|
-- WHERE selectedpieceid IS NOT NULL
|
||||||
|
-- AND selectedpieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT 'piece_product_slots', count(*)
|
||||||
|
-- FROM piece_product_slots
|
||||||
|
-- WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT 'documents', count(*)
|
||||||
|
-- FROM documents
|
||||||
|
-- WHERE pieceid IS NOT NULL
|
||||||
|
-- AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT 'custom_field_values', count(*)
|
||||||
|
-- FROM custom_field_values
|
||||||
|
-- WHERE pieceid IS NOT NULL
|
||||||
|
-- AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT 'piece_constructeur_links', count(*)
|
||||||
|
-- FROM piece_constructeur_links
|
||||||
|
-- WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT 'piece_products', count(*)
|
||||||
|
-- FROM piece_products
|
||||||
|
-- WHERE piece_id NOT IN (SELECT id FROM pieces)
|
||||||
|
-- ORDER BY table_name;
|
||||||
|
--
|
||||||
|
-- COMMIT;
|
||||||
@@ -6,6 +6,7 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\CustomField;
|
use App\Entity\CustomField;
|
||||||
use App\Entity\CustomFieldValue;
|
use App\Entity\CustomFieldValue;
|
||||||
|
use App\Entity\Piece;
|
||||||
use App\Repository\ComposantRepository;
|
use App\Repository\ComposantRepository;
|
||||||
use App\Repository\CustomFieldRepository;
|
use App\Repository\CustomFieldRepository;
|
||||||
use App\Repository\CustomFieldValueRepository;
|
use App\Repository\CustomFieldValueRepository;
|
||||||
@@ -15,6 +16,7 @@ use App\Repository\MachineRepository;
|
|||||||
use App\Repository\PieceRepository;
|
use App\Repository\PieceRepository;
|
||||||
use App\Repository\ProductRepository;
|
use App\Repository\ProductRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\EntityNotFoundException;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -288,28 +290,68 @@ class CustomFieldValueController extends AbstractController
|
|||||||
|
|
||||||
case 'machinePieceLink':
|
case 'machinePieceLink':
|
||||||
$value->setMachinePieceLink($entity);
|
$value->setMachinePieceLink($entity);
|
||||||
$value->setPiece($entity->getPiece());
|
$value->setPiece($this->ensurePieceExists($entity->getPiece()));
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Piece if its underlying row still exists in DB, otherwise null.
|
||||||
|
* getId() on a Doctrine proxy does NOT trigger __load(), so we force the proxy
|
||||||
|
* to initialize explicitly to handle orphan links here instead of crashing on
|
||||||
|
* the first real getter.
|
||||||
|
*/
|
||||||
|
private function ensurePieceExists(?Piece $piece): ?Piece
|
||||||
|
{
|
||||||
|
if (null === $piece) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->entityManager->initializeObject($piece);
|
||||||
|
|
||||||
|
return $piece;
|
||||||
|
} catch (EntityNotFoundException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getId() on a Doctrine proxy returns the identifier without triggering __load(),
|
||||||
|
* so it never raises EntityNotFoundException even if the row is gone. Force the
|
||||||
|
* proxy to initialize explicitly so an orphan CFV is handled here instead of
|
||||||
|
* crashing on the first real getter.
|
||||||
|
*/
|
||||||
|
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
|
||||||
|
{
|
||||||
|
if (null === $cf) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->entityManager->initializeObject($cf);
|
||||||
|
|
||||||
|
return $cf;
|
||||||
|
} catch (EntityNotFoundException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeCustomFieldValue(CustomFieldValue $value): array
|
private function normalizeCustomFieldValue(CustomFieldValue $value): array
|
||||||
{
|
{
|
||||||
$customField = $value->getCustomField();
|
$customField = $this->ensureCustomFieldExists($value->getCustomField());
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $value->getId(),
|
'id' => $value->getId(),
|
||||||
'value' => $value->getValue(),
|
'value' => $value->getValue(),
|
||||||
'customFieldId' => $customField->getId(),
|
'customFieldId' => $customField?->getId(),
|
||||||
'customField' => [
|
'customField' => $customField ? [
|
||||||
'id' => $customField->getId(),
|
'id' => $customField->getId(),
|
||||||
'name' => $customField->getName(),
|
'name' => $customField->getName(),
|
||||||
'type' => $customField->getType(),
|
'type' => $customField->getType(),
|
||||||
'required' => $customField->isRequired(),
|
'required' => $customField->isRequired(),
|
||||||
'options' => $customField->getOptions(),
|
'options' => $customField->getOptions(),
|
||||||
'orderIndex' => $customField->getOrderIndex(),
|
'orderIndex' => $customField->getOrderIndex(),
|
||||||
],
|
] : null,
|
||||||
'machineId' => $value->getMachine()?->getId(),
|
'machineId' => $value->getMachine()?->getId(),
|
||||||
'composantId' => $value->getComposant()?->getId(),
|
'composantId' => $value->getComposant()?->getId(),
|
||||||
'pieceId' => $value->getPiece()?->getId(),
|
'pieceId' => $value->getPiece()?->getId(),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use App\Repository\PieceRepository;
|
|||||||
use App\Repository\ProductRepository;
|
use App\Repository\ProductRepository;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\EntityNotFoundException;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -676,7 +677,7 @@ class MachineStructureController extends AbstractController
|
|||||||
private function normalizePieceLinks(array $links): array
|
private function normalizePieceLinks(array $links): array
|
||||||
{
|
{
|
||||||
return array_map(function (MachinePieceLink $link): array {
|
return array_map(function (MachinePieceLink $link): array {
|
||||||
$piece = $link->getPiece();
|
$piece = $this->ensurePieceExists($link->getPiece());
|
||||||
$modelType = $link->getModelType();
|
$modelType = $link->getModelType();
|
||||||
$parentLink = $link->getParentLink();
|
$parentLink = $link->getParentLink();
|
||||||
$type = $piece?->getTypePiece();
|
$type = $piece?->getTypePiece();
|
||||||
@@ -704,7 +705,7 @@ class MachineStructureController extends AbstractController
|
|||||||
private function resolvePieceQuantity(MachinePieceLink $link): int
|
private function resolvePieceQuantity(MachinePieceLink $link): int
|
||||||
{
|
{
|
||||||
$parentLink = $link->getParentLink();
|
$parentLink = $link->getParentLink();
|
||||||
$piece = $link->getPiece();
|
$piece = $this->ensurePieceExists($link->getPiece());
|
||||||
|
|
||||||
if (!$parentLink || !$piece) {
|
if (!$parentLink || !$piece) {
|
||||||
return $link->getQuantity();
|
return $link->getQuantity();
|
||||||
@@ -716,7 +717,8 @@ class MachineStructureController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($composant->getPieceSlots() as $slot) {
|
foreach ($composant->getPieceSlots() as $slot) {
|
||||||
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
|
$selected = $this->ensurePieceExists($slot->getSelectedPiece());
|
||||||
|
if ($selected?->getId() === $piece->getId()) {
|
||||||
return $slot->getQuantity();
|
return $slot->getQuantity();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -771,15 +773,16 @@ class MachineStructureController extends AbstractController
|
|||||||
{
|
{
|
||||||
$pieces = [];
|
$pieces = [];
|
||||||
foreach ($composant->getPieceSlots() as $slot) {
|
foreach ($composant->getPieceSlots() as $slot) {
|
||||||
|
$selectedPiece = $this->ensurePieceExists($slot->getSelectedPiece());
|
||||||
$pieceData = [
|
$pieceData = [
|
||||||
'slotId' => $slot->getId(),
|
'slotId' => $slot->getId(),
|
||||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||||
'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
|
'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
|
||||||
'quantity' => $slot->getQuantity(),
|
'quantity' => $slot->getQuantity(),
|
||||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
'selectedPieceId' => $selectedPiece?->getId(),
|
||||||
];
|
];
|
||||||
if ($slot->getSelectedPiece()) {
|
if ($selectedPiece) {
|
||||||
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece());
|
$pieceData['resolvedPiece'] = $this->normalizePiece($selectedPiece);
|
||||||
}
|
}
|
||||||
$pieces[] = $pieceData;
|
$pieces[] = $pieceData;
|
||||||
}
|
}
|
||||||
@@ -810,6 +813,46 @@ class MachineStructureController extends AbstractController
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Piece if its underlying row still exists in DB, otherwise null.
|
||||||
|
* getId() on a Doctrine proxy does NOT trigger __load() (the id is the key used
|
||||||
|
* to build the proxy), so we force initialization via initializeObject() to
|
||||||
|
* surface a stale FK here instead of crashing on the first real getter.
|
||||||
|
*/
|
||||||
|
private function ensurePieceExists(?Piece $piece): ?Piece
|
||||||
|
{
|
||||||
|
if (null === $piece) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->entityManager->initializeObject($piece);
|
||||||
|
|
||||||
|
return $piece;
|
||||||
|
} catch (EntityNotFoundException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the CustomField if its underlying row still exists, otherwise null.
|
||||||
|
* getId() on a Doctrine proxy does NOT trigger __load() — the id is the key used
|
||||||
|
* to build the proxy. We force initialization explicitly so a stale FK to a
|
||||||
|
* deleted CustomField surfaces here instead of crashing on getName() later.
|
||||||
|
*/
|
||||||
|
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
|
||||||
|
{
|
||||||
|
if (null === $cf) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->entityManager->initializeObject($cf);
|
||||||
|
|
||||||
|
return $cf;
|
||||||
|
} catch (EntityNotFoundException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizePiece(Piece $piece): array
|
private function normalizePiece(Piece $piece): array
|
||||||
{
|
{
|
||||||
$type = $piece->getTypePiece();
|
$type = $piece->getTypePiece();
|
||||||
@@ -920,7 +963,10 @@ class MachineStructureController extends AbstractController
|
|||||||
if (!$cfv instanceof CustomFieldValue) {
|
if (!$cfv instanceof CustomFieldValue) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$cf = $cfv->getCustomField();
|
$cf = $this->ensureCustomFieldExists($cfv->getCustomField());
|
||||||
|
if (null === $cf) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'id' => $cfv->getId(),
|
'id' => $cfv->getId(),
|
||||||
'value' => $cfv->getValue(),
|
'value' => $cfv->getValue(),
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
@@ -12,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
|
|||||||
use ApiPlatform\Metadata\Post;
|
use ApiPlatform\Metadata\Post;
|
||||||
use ApiPlatform\Metadata\Put;
|
use ApiPlatform\Metadata\Put;
|
||||||
use App\Entity\Trait\CuidEntityTrait;
|
use App\Entity\Trait\CuidEntityTrait;
|
||||||
|
use App\Filter\ConstructeurSearchFilter;
|
||||||
use App\Repository\ConstructeurRepository;
|
use App\Repository\ConstructeurRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
@@ -26,6 +30,9 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
|
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
|
||||||
#[ORM\Table(name: 'constructeurs')]
|
#[ORM\Table(name: 'constructeurs')]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
#[ORM\HasLifecycleCallbacks]
|
||||||
|
#[ApiFilter(ConstructeurSearchFilter::class)]
|
||||||
|
#[ApiFilter(SearchFilter::class, properties: ['categories.id' => 'exact'])]
|
||||||
|
#[ApiFilter(OrderFilter::class, properties: ['name', 'email', 'createdAt'])]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
|
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
|
||||||
operations: [
|
operations: [
|
||||||
@@ -37,7 +44,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
|
||||||
],
|
],
|
||||||
paginationClientItemsPerPage: true,
|
paginationClientItemsPerPage: true,
|
||||||
paginationMaximumItemsPerPage: 200,
|
paginationMaximumItemsPerPage: 2000,
|
||||||
normalizationContext: ['groups' => ['constructeur:read']],
|
normalizationContext: ['groups' => ['constructeur:read']],
|
||||||
denormalizationContext: ['groups' => ['constructeur:write']]
|
denormalizationContext: ['groups' => ['constructeur:write']]
|
||||||
)]
|
)]
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
|
|
||||||
#[ORM\Entity(repositoryClass: MachineRepository::class)]
|
#[ORM\Entity(repositoryClass: MachineRepository::class)]
|
||||||
#[ORM\Table(name: 'machines')]
|
#[ORM\Table(name: 'machines')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_machine_name_site', columns: ['name', 'siteId'])]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
#[ORM\HasLifecycleCallbacks]
|
||||||
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.', ignoreNull: true)]
|
#[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(
|
#[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.',
|
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: [
|
operations: [
|
||||||
@@ -45,7 +47,7 @@ class Machine
|
|||||||
#[Groups(['document:list'])]
|
#[Groups(['document:list'])]
|
||||||
private ?string $id = null;
|
private ?string $id = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||||
#[Groups(['document:list'])]
|
#[Groups(['document:list'])]
|
||||||
private string $name;
|
private string $name;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
|
|||||||
|
|
||||||
$constraint = $this->detectConstraintName($exception);
|
$constraint = $this->detectConstraintName($exception);
|
||||||
$error = match ($constraint) {
|
$error = match ($constraint) {
|
||||||
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
|
'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à.',
|
'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(
|
$event->setResponse(new JsonResponse(
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search filter pour Constructeur : LIKE insensible à la casse sur name, email
|
||||||
|
* + LEFT JOIN sur la collection telephones pour matcher aussi sur telephone.numero.
|
||||||
|
* Param query : ?search=...
|
||||||
|
*/
|
||||||
|
final class ConstructeurSearchFilter extends AbstractFilter
|
||||||
|
{
|
||||||
|
public function getDescription(string $resourceClass): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'search' => [
|
||||||
|
'property' => null,
|
||||||
|
'type' => 'string',
|
||||||
|
'required' => false,
|
||||||
|
'description' => 'Recherche dans le nom, l\'email et les numéros de téléphone du fournisseur.',
|
||||||
|
'openapi' => [
|
||||||
|
'allowEmptyValue' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
|
||||||
|
{
|
||||||
|
if ('search' !== $property || !is_string($value) || '' === trim($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$alias = $queryBuilder->getRootAliases()[0];
|
||||||
|
$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
|
||||||
|
->andWhere(sprintf(
|
||||||
|
'LOWER(%1$s.name) LIKE :%2$s OR LOWER(%1$s.email) LIKE :%2$s OR EXISTS (%3$s)',
|
||||||
|
$alias,
|
||||||
|
$paramName,
|
||||||
|
$phoneSubQuery,
|
||||||
|
))
|
||||||
|
->setParameter($paramName, $likePattern)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -134,6 +134,88 @@ class MachineTest extends AbstractApiTestCase
|
|||||||
$this->assertResponseStatusCodeSame(422);
|
$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
|
public function testGetStructureEndpoint(): void
|
||||||
{
|
{
|
||||||
$machine = $this->createMachine('Machine structure');
|
$machine = $this->createMachine('Machine structure');
|
||||||
|
|||||||
Reference in New Issue
Block a user