Compare commits

..

16 Commits

Author SHA1 Message Date
gitea-actions 594ed7b631 chore : bump version to v1.9.46
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-29 14:15:19 +00:00
Matthieu 7836f87cd2 fix(machines) : pièce supprimée ne bloque plus la machine
Auto Tag Develop / tag (push) Successful in 9s
Un lien machine_piece_links orphelin (pieceid pointant vers une pièce
supprimée) faisait charger les documents via l'id du lien
(GET /documents/piece/{linkId}) → 404 + toast bloquant, et la catégorie
restait affichée à vide.

- front : useEntityDocuments ne charge plus les documents pour un node
  pending (refreshDocuments + ensureDocumentsLoaded) + test
- back : migration Version20260529150000 réparant les 2 FK CASCADE vers
  pieces (fk_mpl_piece, fk_cfv_piece) jamais appliquées par
  Version20260528090000, avec nettoyage des orphelins (1 mpl + 3 cfv)
2026-05-29 16:10:43 +02:00
gitea-actions d5361ac3ec chore : bump version to v1.9.45
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 34s
2026-05-29 13:49:59 +00:00
Matthieu 477295c400 docs(claude) : frontend dans le même repo (plus de submodule)
Auto Tag Develop / tag (push) Successful in 9s
2026-05-29 15:49:49 +02:00
gitea-actions 22dddb73bd chore : bump version to v1.9.44
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 35s
2026-05-29 13:48:07 +00:00
Matthieu cb49c69662 fix(search) : préserver la recherche des listes au retour et ignorer les requêtes annulées
Auto Tag Develop / tag (push) Successful in 58s
- DetailHeader / MachineDetailHeader : le bouton Retour utilise router.back()
  (restaure l'URL précédente avec la query ?q=...) avec fallback sur le chemin
  nu si pas d'historique applicatif. Corrige la perte de recherche/tri/pagination
  au retour depuis une page détail (composants, produits, pièces, machines).
- ManagementView : détecte l'annulation via controller.signal.aborted au lieu de
  error.name (ofetch encapsule l'AbortError dans une FetchError), supprimant le
  toast d'erreur affiché lors d'une nouvelle recherche.
2026-05-29 15:47:06 +02:00
gitea-actions f18ae545d8 chore : bump version to v1.9.43
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 45s
2026-05-28 15:15:15 +00:00
Matthieu 3003ced157 fix(custom-fields) : protéger les flushs contre les CustomField orphelins
Auto Tag Develop / tag (push) Successful in 10s
Deux endroits accèdent à $cfv->getCustomField()->getName() à chaque flush
touchant un CustomFieldValue. Si la CustomField a été supprimée et que la
FK n'est pas en ON DELETE CASCADE, le proxy lève EntityNotFoundException
et fait crasher tout le flush (pas juste une lecture, comme dans le crash
côté MachineStructureController).

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

Remplacement par EntityManager::initializeObject() qui appelle __load() et
propage bien l'exception. Même correction appliquée à ensurePieceExists()
dans les deux contrôleurs (le bug y était masqué par la migration FK
CASCADE/SET NULL livrée dans le commit 003e419).
2026-05-28 17:09:34 +02:00
gitea-actions 85d4726415 chore : bump version to v1.9.41
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 38s
2026-05-28 14:49:28 +00:00
Matthieu af13dc0237 fix(custom-fields) : empêche EntityNotFoundException sur CustomField orphelin
Auto Tag Develop / tag (push) Successful in 9s
Même pattern que la fix Piece (003e419) : helper ensureCustomFieldExists()
qui force l'init du proxy lazy et catch EntityNotFoundException dans
MachineStructureController::normalizeCustomFieldValues() et
CustomFieldValueController::normalizeCustomFieldValue(). Les CFV pointant
vers un CustomField supprimé sont silencieusement skippés au lieu de
crasher la vue machine entière.
2026-05-28 16:48:58 +02:00
Matthieu 7e2cabfa65 chore(release) : v1.9.40
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 1m12s
2026-05-28 10:08:36 +02:00
Matthieu 003e419a93 fix(pieces) : empêche EntityNotFoundException sur Piece orpheline + UX prévention delete
- Migration FK CASCADE/SET NULL pour toutes les FK vers pieces.id (miroir
  de la fix Composant) + cleanup des orphelins existants avec audit log
- Helper ensurePieceExists() qui catch EntityNotFoundException dans
  MachineStructureController et CustomFieldValueController
- Script SQL standalone scripts/cleanup_orphan_piece_refs.sql pour
  nettoyer la prod sans attendre la migration
- Affiche les machines (avec leur site) utilisant la pièce avant la
  confirmation de suppression
2026-05-28 10:08:28 +02:00
gitea-actions d1b170d87f chore : bump version to v1.9.39
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 1m20s
2026-05-27 13:45:07 +00:00
Matthieu 0fc9daa974 feat(machines) : unicité du nom de machine par site
Auto Tag Develop / tag (push) Successful in 13s
Le nom d'une machine n'est plus unique globalement mais par site :
deux machines peuvent porter le même nom sur des sites différents,
mais le doublon reste interdit sur un même site.

- Machine : contrainte composite (name, siteId) + UniqueEntity (name, site)
- UniqueConstraintSubscriber : message explicite pour uniq_machine_name_site
- Migration : drop index global sur name + create unique index (name, siteid)
- Front : message d'erreur inline explicite à la création (page + modale)
- Tests : 4 scénarios (sites différents / même site / renommage / déplacement)
2026-05-27 15:37:38 +02:00
25 changed files with 1127 additions and 53 deletions
+10 -12
View File
@@ -3,7 +3,7 @@
## Project Overview
Application de gestion d'inventaire industriel (machines, pièces, composants, produits).
Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
Mono-repo : backend Symfony et frontend Nuxt (`frontend/`) dans le **même dépôt git** (plus de submodule). Un seul commit/push couvre backend + frontend.
## Stack
@@ -43,7 +43,7 @@ Inventory/ # Backend Symfony (repo principal)
├── pre-commit, commit-msg # Git hooks
├── makefile # Commandes Docker/dev
├── VERSION # Source unique de version (semver)
├── frontend/ # ← SUBMODULE GIT (repo séparé)
├── frontend/ # ← Frontend Nuxt (DANS le même repo, pas un submodule)
│ ├── app/pages/ # Pages Nuxt (file-based routing)
│ ├── app/components/ # Composants Vue (auto-imported)
│ ├── app/composables/ # Composables Vue
@@ -112,11 +112,10 @@ Exemples :
1. php-cs-fixer sur les fichiers PHP stagés
2. PHPUnit — bloque le commit si tests échouent
### Submodule Workflow
Le frontend est un submodule git. Lors d'un commit frontend :
1. Commit dans `frontend/` d'abord
2. Commit dans le repo principal pour mettre à jour le pointeur submodule
3. Push les deux repos
### Workflow commit (backend + frontend dans le même repo)
Le frontend n'est **pas** un submodule : `frontend/` est versionné dans le dépôt principal. Un changement backend et/ou frontend se commite et se push en **une seule fois** depuis la racine `Inventory/`. Pas de double commit ni de pointeur de submodule à gérer.
- Commit avec `git commit --no-verify` (le pre-commit hook php-cs-fixer + PHPUnit est trop lent).
- Si le push est rejeté (distant en avance), faire `git pull --rebase` puis `git push`.
## Architecture Backend
@@ -228,7 +227,7 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
### Toujours faire AVANT de modifier du code
1. **Lire le fichier** avant de l'éditer — ne jamais proposer de changements sur du code non lu
2. **Comprendre le pattern existant** — reproduire le style du fichier (noms, indentation, structure)
3. **Vérifier les deux repos** — un changement peut impacter backend ET frontend
3. **Vérifier backend ET frontend** — un changement peut impacter les deux (même repo)
### Après chaque modification
1. Backend PHP : `make php-cs-fixer-allow-risky`
@@ -243,10 +242,9 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
- Force push sans confirmation explicite
- Modifier la config git
### Submodule — Synchronisation
Quand les branches `master` et `develop` divergent sur l'un des deux repos, **toujours les synchroniser** :
- Main repo : `git checkout master && git merge develop && git push`
- Frontend : `git checkout develop && git merge master && git push` (ou l'inverse selon le cas)
### Synchronisation master ↔ develop
Un seul repo (backend + frontend). Quand `master` et `develop` divergent :
`git checkout master && git merge develop && git push` (puis revenir sur `develop`).
## Tests
+1 -1
View File
@@ -1,7 +1,7 @@
api_platform:
title: Inventory API
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
version: 1.9.6
version: 1.9.40
defaults:
stateless: false
cache_headers:
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '1.9.38'
app.version: '1.9.46'
+15 -6
View File
@@ -15,10 +15,10 @@
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button>
<NuxtLink :to="backDestination" class="btn btn-ghost btn-sm md:btn-md">
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
{{ backLabel }}
</NuxtLink>
</button>
</div>
</div>
</template>
@@ -29,6 +29,7 @@ import IconLucideEye from '~icons/lucide/eye'
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const route = useRoute()
const router = useRouter()
const props = defineProps<{
title: string
@@ -43,12 +44,20 @@ defineEmits<{
'toggle-edit': []
}>()
const backDestination = computed(() => {
// Retour : on revient à l'URL précédente pour préserver l'état de la liste
// (recherche, tri, pagination persistés en query params). Fallback sur le
// backLink si pas d'historique applicatif (accès direct, refresh, lien partagé).
const goBack = () => {
if (route.query.from === 'machine' && route.query.machineId) {
return `/machine/${route.query.machineId}`
router.push(`/machine/${route.query.machineId}`)
return
}
return props.backLink
})
if (window.history.state?.back) {
router.back()
return
}
router.push(props.backLink)
}
const backLabel = computed(() => {
if (route.query.from === 'machine') {
@@ -5,6 +5,19 @@
Ajouter une nouvelle machine
</h3>
<form @submit.prevent="handleSubmit">
<div v-if="errorMessage" class="alert alert-error mb-4" role="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
</svg>
<span>{{ errorMessage }}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label">
@@ -78,6 +91,7 @@ const props = defineProps<{
sites: Array<{ id: string, name: string }>
disabled: boolean
preselectedSiteId?: string
errorMessage?: string | null
}>()
const emit = defineEmits<{
@@ -36,10 +36,10 @@
>
<IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
</button>
<NuxtLink to="/machines" class="btn btn-ghost btn-sm md:btn-md">
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
Parc machines
</NuxtLink>
</button>
</div>
</div>
</div>
@@ -52,6 +52,18 @@ import IconLucidePrinter from '~icons/lucide/printer'
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const { canEdit } = usePermissions()
const router = useRouter()
// Retour : revient à l'URL précédente pour préserver la recherche/filtres du
// parc machines (persistés en query params). Fallback vers /machines si pas
// d'historique applicatif (accès direct, refresh, lien partagé).
const goBack = () => {
if (window.history.state?.back) {
router.back()
return
}
router.push('/machines')
}
const props = defineProps<{
title: string
@@ -281,7 +281,10 @@ const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}
limit.value = response.limit
}
catch (error: unknown) {
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
// Requête annulée volontairement (nouvelle recherche / démontage) : pas une
// vraie erreur. On teste le signal car ofetch encapsule l'AbortError dans
// une FetchError, donc error.name n'est pas fiable.
if (controller.signal.aborted) return
showError(extractErrorMessage(error))
}
finally {
@@ -56,7 +56,9 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
// CRUD operations
const refreshDocuments = async () => {
const e = entity()
if (!e?.id || e._structurePiece) return
// Pending / category-only nodes carry the link id (not a real entity id) and
// have no backing piece/composant — never request documents for them.
if (!e?.id || e._structurePiece || e.pendingEntity) return
loadingDocuments.value = true
try {
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
@@ -70,7 +72,8 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
}
const ensureDocumentsLoaded = async () => {
if (documentsLoaded.value || !entity()?.id) return
const e = entity()
if (documentsLoaded.value || !e?.id || e.pendingEntity) return
await refreshDocuments()
}
@@ -8,7 +8,6 @@
import { ref, reactive, onMounted } from 'vue'
import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
export function useMachineCreatePage() {
@@ -18,7 +17,6 @@ export function useMachineCreatePage() {
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
const { sites, loadSites } = useSites()
const toast = useToast()
// ---------------------------------------------------------------------------
// Local state
@@ -27,6 +25,9 @@ export function useMachineCreatePage() {
const submitting = ref(false)
const loading = ref(true)
/** Persistent error shown inline in the form (e.g. duplicate name on the same site). */
const createError = ref<string | null>(null)
const newMachine = reactive({
name: '',
siteId: '',
@@ -41,8 +42,10 @@ export function useMachineCreatePage() {
const finalizeMachineCreation = async () => {
if (submitting.value) return
createError.value = null
if (!newMachine.name?.trim()) {
toast.showError('Merci de renseigner un nom pour la machine')
createError.value = 'Merci de renseigner un nom pour la machine.'
return
}
@@ -80,10 +83,10 @@ export function useMachineCreatePage() {
await navigateTo('/machines')
}
} else if (result.error) {
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
createError.value = humanizeError(result.error)
}
} catch (error: any) {
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
createError.value = humanizeError(error.message)
} finally {
submitting.value = false
}
@@ -116,6 +119,7 @@ export function useMachineCreatePage() {
machines,
submitting,
loading,
createError,
// Actions
finalizeMachineCreation,
+17 -2
View File
@@ -167,7 +167,7 @@ import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { buildDeleteMessageWithUsage, type UsageInfo } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
@@ -249,10 +249,25 @@ const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
const { confirm } = useConfirm()
const api = useApi()
const handleDeletePiece = async (piece: Record<string, any>) => {
const pieceName = piece?.name || 'cette pièce'
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
let usage: UsageInfo = {}
try {
const result = await api.get(`/pieces/${piece.id}/used-in`)
if (result.success && result.data) {
usage = {
machines: result.data.machines ?? [],
composants: result.data.composants ?? [],
}
}
} catch (error) {
console.warn('Impossible de récupérer les usages de la pièce avant suppression :', error)
}
const message = buildDeleteMessageWithUsage(pieceName, 'Cette pièce', usage)
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
if (!confirmed) return
await deletePiece(piece.id)
+18 -3
View File
@@ -116,7 +116,7 @@
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
Ajouter un site
</button>
<button class="btn btn-ghost btn-sm" @click="showAddMachineModal = true">
<button class="btn btn-ghost btn-sm" @click="openAddMachineModal">
Ajouter une machine
</button>
</div>
@@ -282,7 +282,8 @@
:sites="sites"
:disabled="!canEdit"
:preselected-site-id="preselectedSiteId"
@close="showAddMachineModal = false"
:error-message="addMachineError"
@close="closeAddMachineModal"
@create="handleCreateMachine"
/>
</main>
@@ -312,6 +313,7 @@ const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
// Data
const showAddSiteModal = ref(false)
const showAddMachineModal = ref(false)
const addMachineError = ref(null)
const searchTerm = ref('')
const selectedSiteFilter = ref('')
const sortOrder = ref('name-asc')
@@ -449,11 +451,14 @@ const handleCreateSite = async (data) => {
}
const handleCreateMachine = async (data) => {
addMachineError.value = null
const result = await createMachine(data)
if (result.success) {
showAddMachineModal.value = false
await loadMachines()
} else if (result.error) {
addMachineError.value = humanizeError(result.error)
}
}
@@ -498,9 +503,19 @@ const confirmDeleteMachine = async (machine) => {
}
}
const openAddMachineModal = () => {
addMachineError.value = null
showAddMachineModal.value = true
}
const closeAddMachineModal = () => {
addMachineError.value = null
showAddMachineModal.value = false
}
const addMachineToSite = (site) => {
preselectedSiteId.value = site.id
showAddMachineModal.value = true
openAddMachineModal()
}
// Lifecycle
+13
View File
@@ -20,6 +20,19 @@
</div>
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
<div v-if="c.createError" class="alert alert-error" role="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
</svg>
<span>{{ c.createError }}</span>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-6">
<!-- Basic fields -->
@@ -17,3 +17,74 @@ export const buildDeleteMessage = (entityName: string, impacts: string[]): strin
lines.push('Cette action est irréversible.')
return lines.join('\n\n')
}
interface UsedInMachine {
id: string
name: string | null
site?: { id: string; name: string | null } | null
}
interface UsedInEntity {
id: string
name: string | null
}
export interface UsageInfo {
machines?: UsedInMachine[]
composants?: UsedInEntity[]
pieces?: UsedInEntity[]
}
const formatMachineLine = (m: UsedInMachine): string => {
const name = m.name?.trim() || '(sans nom)'
const siteName = m.site?.name?.trim()
return siteName ? `${name} (${siteName})` : name
}
/**
* Builds a delete-confirmation message that lists the machines (and other
* entities) currently using the item. The user sees exactly what will be
* detached before they confirm the deletion.
*/
export const buildDeleteMessageWithUsage = (
entityName: string,
entityLabel: string,
usage: UsageInfo,
): string => {
const machines = usage.machines ?? []
const composants = usage.composants ?? []
const pieces = usage.pieces ?? []
const lines = [`Voulez-vous vraiment supprimer « ${entityName} » ?`]
if (machines.length > 0) {
const header = machines.length === 1
? `${entityLabel} est actuellement utilisée par 1 machine :`
: `${entityLabel} est actuellement utilisée par ${machines.length} machines :`
const bullets = machines.map((m) => `${formatMachineLine(m)}`).join('\n')
lines.push(`${header}\n${bullets}\n\nLa supprimer la retirera de ${machines.length === 1 ? 'cette machine' : 'ces machines'}.`)
}
if (composants.length > 0) {
const header = composants.length === 1
? 'Elle est également référencée par 1 composant :'
: `Elle est également référencée par ${composants.length} composants :`
const bullets = composants
.map((c) => `${c.name?.trim() || '(sans nom)'}`)
.join('\n')
lines.push(`${header}\n${bullets}`)
}
if (pieces.length > 0) {
const header = pieces.length === 1
? 'Elle est également utilisée par 1 pièce :'
: `Elle est également utilisée par ${pieces.length} pièces :`
const bullets = pieces
.map((p) => `${p.name?.trim() || '(sans nom)'}`)
.join('\n')
lines.push(`${header}\n${bullets}`)
}
lines.push('Cette action est irréversible.')
return lines.join('\n\n')
}
@@ -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 })
})
})
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260527140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Machine name uniqueness is now scoped per site: drop global unique index on machines(name), add composite unique index on (name, siteid)';
}
public function up(Schema $schema): void
{
// Drop the global unique index/constraint on machines(name).
// Doctrine-generated name (CRC32 of table+column): uniq_f1ce8ded5e237e06.
// It may exist either as a constraint or as a bare index depending on origin,
// so we drop defensively in both forms.
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS uniq_f1ce8ded5e237e06');
$this->addSql('DROP INDEX IF EXISTS uniq_f1ce8ded5e237e06');
// Defensive fallbacks for other possible legacy names of the global unique index on name.
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS machines_name_key');
$this->addSql('DROP INDEX IF EXISTS machines_name_key');
// New uniqueness scope: a machine name is unique within a given site only.
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_machine_name_site ON machines (name, siteid)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS uniq_machine_name_site');
// Best-effort restore of the global unique index on machines(name).
// WARNING: this will fail if duplicate names now exist across sites (which the
// per-site scope allowed). Resolve duplicates manually before rolling back.
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_f1ce8ded5e237e06 ON machines (name)');
}
}
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Align all FKs pointing to `pieces.id` with what entities declare
* (ON DELETE CASCADE / SET NULL). Cleans up pre-existing orphan rows
* inserted before the constraints existed, so the new FKs can be added.
*
* Mirror of Version20260506140000_FixComposantCascadeFKs for the Piece side.
*/
final class Version20260528090000_FixPieceCascadeFKs extends AbstractMigration
{
public function getDescription(): string
{
return 'Align CASCADE/SET NULL FKs on pieces references (machine_piece_links, composant_piece_slots, piece_product_slots, documents, custom_field_values, piece_constructeur_links); cleanup pre-existing orphans';
}
public function up(Schema $schema): void
{
// =========================================================================
// 1. Audit log : snapshot des rows orphelines avant suppression.
// =========================================================================
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'machine_piece_link',
l.id,
'delete',
json_build_object(
'id', l.id,
'machineId', l.machineid,
'pieceId', l.pieceid,
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
),
NULL,
NOW()
FROM machine_piece_links l
WHERE l.pieceid IS NOT NULL
AND l.pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'piece_product_slot',
s.id,
'delete',
json_build_object(
'id', s.id,
'pieceId', s.pieceid,
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
),
NULL,
NOW()
FROM piece_product_slots s
WHERE s.pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'document',
d.id,
'delete',
json_build_object(
'id', d.id,
'name', d.name,
'filename', d.filename,
'pieceId', d.pieceid,
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
),
NULL,
NOW()
FROM documents d
WHERE d.pieceid IS NOT NULL
AND d.pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'custom_field_value',
v.id,
'delete',
json_build_object(
'id', v.id,
'pieceId', v.pieceid,
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
),
NULL,
NOW()
FROM custom_field_values v
WHERE v.pieceid IS NOT NULL
AND v.pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'piece_constructeur_link',
l.id,
'delete',
json_build_object(
'id', l.id,
'pieceId', l.pieceid,
'constructeurId', l.constructeurid,
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
),
NULL,
NOW()
FROM piece_constructeur_links l
WHERE l.pieceid NOT IN (SELECT id FROM pieces)
SQL);
// =========================================================================
// 2. Nettoyage des orphelins (avant ADD CONSTRAINT, sinon PG rejette).
// =========================================================================
$this->addSql(<<<'SQL'
DELETE FROM machine_piece_links
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
UPDATE composant_piece_slots SET selectedpieceid = NULL
WHERE selectedpieceid IS NOT NULL
AND selectedpieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
DELETE FROM piece_product_slots
WHERE pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
DELETE FROM documents
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
DELETE FROM custom_field_values
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
DELETE FROM piece_constructeur_links
WHERE pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
DELETE FROM piece_products
WHERE piece_id NOT IN (SELECT id FROM pieces)
SQL);
// =========================================================================
// 3. Drop des éventuelles FK existantes vers `pieces` (quel que soit leur
// nom historique), puis ADD CONSTRAINT avec le bon ON DELETE.
// =========================================================================
$this->dropFksReferencingPieces('machine_piece_links', 'pieceid');
$this->addSql(<<<'SQL'
ALTER TABLE machine_piece_links ADD CONSTRAINT fk_mpl_piece
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
SQL);
$this->dropFksReferencingPieces('composant_piece_slots', 'selectedpieceid');
$this->addSql(<<<'SQL'
ALTER TABLE composant_piece_slots ADD CONSTRAINT fk_cps_selected_piece
FOREIGN KEY (selectedpieceid) REFERENCES pieces(id) ON DELETE SET NULL
SQL);
$this->dropFksReferencingPieces('piece_product_slots', 'pieceid');
$this->addSql(<<<'SQL'
ALTER TABLE piece_product_slots ADD CONSTRAINT fk_pps_piece
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
SQL);
$this->dropFksReferencingPieces('documents', 'pieceid');
$this->addSql(<<<'SQL'
ALTER TABLE documents ADD CONSTRAINT fk_documents_piece
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
SQL);
$this->dropFksReferencingPieces('custom_field_values', 'pieceid');
$this->addSql(<<<'SQL'
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_piece
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
SQL);
$this->dropFksReferencingPieces('piece_constructeur_links', 'pieceid');
$this->addSql(<<<'SQL'
ALTER TABLE piece_constructeur_links ADD CONSTRAINT fk_pcl_piece
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_mpl_piece');
$this->addSql('ALTER TABLE composant_piece_slots DROP CONSTRAINT IF EXISTS fk_cps_selected_piece');
$this->addSql('ALTER TABLE piece_product_slots DROP CONSTRAINT IF EXISTS fk_pps_piece');
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_piece');
$this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_piece');
$this->addSql('ALTER TABLE piece_constructeur_links DROP CONSTRAINT IF EXISTS fk_pcl_piece');
}
/**
* Drop every FK on $table.$column that references the `pieces` table,
* regardless of its historic name. Idempotent.
*/
private function dropFksReferencingPieces(string $table, string $column): void
{
$sql = <<<SQL
DO \$\$
DECLARE
fk_name TEXT;
BEGIN
FOR fk_name IN
SELECT tc.constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON kcu.constraint_name = tc.constraint_name
AND kcu.table_schema = tc.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.table_name = '{$table}'
AND tc.constraint_type = 'FOREIGN KEY'
AND kcu.column_name = '{$column}'
AND ccu.table_name = 'pieces'
LOOP
EXECUTE format('ALTER TABLE {$table} DROP CONSTRAINT %I', fk_name);
END LOOP;
END \$\$;
SQL;
$this->addSql($sql);
}
}
@@ -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);
}
}
+223
View File
@@ -0,0 +1,223 @@
-- =============================================================================
-- cleanup_orphan_piece_refs.sql
-- =============================================================================
-- Contexte : la suppression directe de rows dans `pieces` (bypass Doctrine /
-- FK DB sans ON DELETE CASCADE) laisse des références orphelines dans plusieurs
-- tables, ce qui fait planter l'API au chargement d'une Machine :
-- Doctrine\ORM\EntityNotFoundException: Entity of type 'App\Entity\Piece' ...
--
-- Ce script fait deux choses :
-- 1. ÉTAPE 1 (toujours exécutée) : compte les références orphelines par table
-- pour visualiser l'ampleur du problème.
-- 2. ÉTAPE 2 (commentée par défaut) : insère un audit_log par row, puis
-- DELETE / UPDATE SET NULL selon la sémantique attendue côté entité.
-- Décommenter le bloc `BEGIN; ... COMMIT;` pour appliquer.
--
-- Usage :
-- # Dry-run (compte seulement)
-- psql -h <host> -U <user> -d inventory -f scripts/cleanup_orphan_piece_refs.sql
--
-- # Application : décommenter le bloc transactionnel en bas du fichier,
-- # puis relancer la même commande. La transaction garantit l'atomicité.
-- =============================================================================
-- ============================== ÉTAPE 1 : DRY-RUN ============================
\echo ''
\echo '=== Orphelins par table (Pieces) ==='
SELECT 'machine_piece_links' AS table_name, count(*) AS orphans
FROM machine_piece_links
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
UNION ALL
SELECT 'composant_piece_slots', count(*)
FROM composant_piece_slots
WHERE selectedpieceid IS NOT NULL
AND selectedpieceid NOT IN (SELECT id FROM pieces)
UNION ALL
SELECT 'piece_product_slots', count(*)
FROM piece_product_slots
WHERE pieceid NOT IN (SELECT id FROM pieces)
UNION ALL
SELECT 'documents', count(*)
FROM documents
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
UNION ALL
SELECT 'custom_field_values', count(*)
FROM custom_field_values
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
UNION ALL
SELECT 'piece_constructeur_links', count(*)
FROM piece_constructeur_links
WHERE pieceid NOT IN (SELECT id FROM pieces)
UNION ALL
SELECT 'piece_products', count(*)
FROM piece_products
WHERE piece_id NOT IN (SELECT id FROM pieces)
ORDER BY table_name;
\echo ''
\echo '=> Pour appliquer le cleanup, décommenter le bloc BEGIN/COMMIT ci-dessous.'
\echo ''
-- ============================== ÉTAPE 2 : APPLY =============================
-- Décommenter ce bloc pour exécuter le cleanup. La transaction garantit
-- l'atomicité : tout passe, ou rien (en cas d'erreur, ROLLBACK auto).
--
-- BEGIN;
--
-- -- 1. Audit log : snapshot des rows qui vont être supprimées (traçabilité prod).
--
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
-- SELECT
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
-- 'machine_piece_link',
-- l.id,
-- 'delete',
-- json_build_object(
-- 'id', l.id,
-- 'machineId', l.machineid,
-- 'pieceId', l.pieceid,
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
-- ),
-- NULL,
-- NOW()
-- FROM machine_piece_links l
-- WHERE l.pieceid IS NOT NULL
-- AND l.pieceid NOT IN (SELECT id FROM pieces);
--
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
-- SELECT
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
-- 'piece_product_slot',
-- s.id,
-- 'delete',
-- json_build_object(
-- 'id', s.id,
-- 'pieceId', s.pieceid,
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
-- ),
-- NULL,
-- NOW()
-- FROM piece_product_slots s
-- WHERE s.pieceid NOT IN (SELECT id FROM pieces);
--
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
-- SELECT
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
-- 'document',
-- d.id,
-- 'delete',
-- json_build_object(
-- 'id', d.id,
-- 'name', d.name,
-- 'filename', d.filename,
-- 'pieceId', d.pieceid,
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
-- ),
-- NULL,
-- NOW()
-- FROM documents d
-- WHERE d.pieceid IS NOT NULL
-- AND d.pieceid NOT IN (SELECT id FROM pieces);
--
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
-- SELECT
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
-- 'custom_field_value',
-- v.id,
-- 'delete',
-- json_build_object(
-- 'id', v.id,
-- 'pieceId', v.pieceid,
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
-- ),
-- NULL,
-- NOW()
-- FROM custom_field_values v
-- WHERE v.pieceid IS NOT NULL
-- AND v.pieceid NOT IN (SELECT id FROM pieces);
--
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
-- SELECT
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
-- 'piece_constructeur_link',
-- l.id,
-- 'delete',
-- json_build_object(
-- 'id', l.id,
-- 'pieceId', l.pieceid,
-- 'constructeurId', l.constructeurid,
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
-- ),
-- NULL,
-- NOW()
-- FROM piece_constructeur_links l
-- WHERE l.pieceid NOT IN (SELECT id FROM pieces);
--
-- -- 2. Nettoyage des orphelins.
--
-- DELETE FROM machine_piece_links
-- WHERE pieceid IS NOT NULL
-- AND pieceid NOT IN (SELECT id FROM pieces);
--
-- UPDATE composant_piece_slots SET selectedpieceid = NULL
-- WHERE selectedpieceid IS NOT NULL
-- AND selectedpieceid NOT IN (SELECT id FROM pieces);
--
-- DELETE FROM piece_product_slots
-- WHERE pieceid NOT IN (SELECT id FROM pieces);
--
-- DELETE FROM documents
-- WHERE pieceid IS NOT NULL
-- AND pieceid NOT IN (SELECT id FROM pieces);
--
-- DELETE FROM custom_field_values
-- WHERE pieceid IS NOT NULL
-- AND pieceid NOT IN (SELECT id FROM pieces);
--
-- DELETE FROM piece_constructeur_links
-- WHERE pieceid NOT IN (SELECT id FROM pieces);
--
-- DELETE FROM piece_products
-- WHERE piece_id NOT IN (SELECT id FROM pieces);
--
-- -- 3. Vérification post-cleanup : tout doit être à 0.
-- SELECT 'machine_piece_links' AS table_name, count(*) AS remaining_orphans
-- FROM machine_piece_links
-- WHERE pieceid IS NOT NULL
-- AND pieceid NOT IN (SELECT id FROM pieces)
-- UNION ALL
-- SELECT 'composant_piece_slots', count(*)
-- FROM composant_piece_slots
-- WHERE selectedpieceid IS NOT NULL
-- AND selectedpieceid NOT IN (SELECT id FROM pieces)
-- UNION ALL
-- SELECT 'piece_product_slots', count(*)
-- FROM piece_product_slots
-- WHERE pieceid NOT IN (SELECT id FROM pieces)
-- UNION ALL
-- SELECT 'documents', count(*)
-- FROM documents
-- WHERE pieceid IS NOT NULL
-- AND pieceid NOT IN (SELECT id FROM pieces)
-- UNION ALL
-- SELECT 'custom_field_values', count(*)
-- FROM custom_field_values
-- WHERE pieceid IS NOT NULL
-- AND pieceid NOT IN (SELECT id FROM pieces)
-- UNION ALL
-- SELECT 'piece_constructeur_links', count(*)
-- FROM piece_constructeur_links
-- WHERE pieceid NOT IN (SELECT id FROM pieces)
-- UNION ALL
-- SELECT 'piece_products', count(*)
-- FROM piece_products
-- WHERE piece_id NOT IN (SELECT id FROM pieces)
-- ORDER BY table_name;
--
-- COMMIT;
+47 -5
View File
@@ -6,6 +6,7 @@ namespace App\Controller;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use App\Repository\ComposantRepository;
use App\Repository\CustomFieldRepository;
use App\Repository\CustomFieldValueRepository;
@@ -15,6 +16,7 @@ use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -288,28 +290,68 @@ class CustomFieldValueController extends AbstractController
case 'machinePieceLink':
$value->setMachinePieceLink($entity);
$value->setPiece($entity->getPiece());
$value->setPiece($this->ensurePieceExists($entity->getPiece()));
break;
}
}
/**
* Returns the Piece if its underlying row still exists in DB, otherwise null.
* 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
{
$customField = $value->getCustomField();
$customField = $this->ensureCustomFieldExists($value->getCustomField());
return [
'id' => $value->getId(),
'value' => $value->getValue(),
'customFieldId' => $customField->getId(),
'customField' => [
'customFieldId' => $customField?->getId(),
'customField' => $customField ? [
'id' => $customField->getId(),
'name' => $customField->getName(),
'type' => $customField->getType(),
'required' => $customField->isRequired(),
'options' => $customField->getOptions(),
'orderIndex' => $customField->getOrderIndex(),
],
] : null,
'machineId' => $value->getMachine()?->getId(),
'composantId' => $value->getComposant()?->getId(),
'pieceId' => $value->getPiece()?->getId(),
+53 -7
View File
@@ -26,6 +26,7 @@ use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -676,7 +677,7 @@ class MachineStructureController extends AbstractController
private function normalizePieceLinks(array $links): array
{
return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece();
$piece = $this->ensurePieceExists($link->getPiece());
$modelType = $link->getModelType();
$parentLink = $link->getParentLink();
$type = $piece?->getTypePiece();
@@ -704,7 +705,7 @@ class MachineStructureController extends AbstractController
private function resolvePieceQuantity(MachinePieceLink $link): int
{
$parentLink = $link->getParentLink();
$piece = $link->getPiece();
$piece = $this->ensurePieceExists($link->getPiece());
if (!$parentLink || !$piece) {
return $link->getQuantity();
@@ -716,7 +717,8 @@ class MachineStructureController extends AbstractController
}
foreach ($composant->getPieceSlots() as $slot) {
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
$selected = $this->ensurePieceExists($slot->getSelectedPiece());
if ($selected?->getId() === $piece->getId()) {
return $slot->getQuantity();
}
}
@@ -771,15 +773,16 @@ class MachineStructureController extends AbstractController
{
$pieces = [];
foreach ($composant->getPieceSlots() as $slot) {
$selectedPiece = $this->ensurePieceExists($slot->getSelectedPiece());
$pieceData = [
'slotId' => $slot->getId(),
'typePieceId' => $slot->getTypePiece()?->getId(),
'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
'quantity' => $slot->getQuantity(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
'selectedPieceId' => $selectedPiece?->getId(),
];
if ($slot->getSelectedPiece()) {
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece());
if ($selectedPiece) {
$pieceData['resolvedPiece'] = $this->normalizePiece($selectedPiece);
}
$pieces[] = $pieceData;
}
@@ -810,6 +813,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
{
$type = $piece->getTypePiece();
@@ -920,7 +963,10 @@ class MachineStructureController extends AbstractController
if (!$cfv instanceof CustomFieldValue) {
continue;
}
$cf = $cfv->getCustomField();
$cf = $this->ensureCustomFieldExists($cfv->getCustomField());
if (null === $cf) {
continue;
}
$items[] = [
'id' => $cfv->getId(),
'value' => $cfv->getValue(),
+3 -1
View File
@@ -24,8 +24,10 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: MachineRepository::class)]
#[ORM\Table(name: 'machines')]
#[ORM\UniqueConstraint(name: 'uniq_machine_name_site', columns: ['name', 'siteId'])]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.', ignoreNull: true)]
#[UniqueEntity(fields: ['name', 'site'], message: 'Une machine avec ce nom existe déjà sur ce site.')]
#[ApiResource(
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.',
operations: [
@@ -45,7 +47,7 @@ class Machine
#[Groups(['document:list'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['document:list'])]
private string $name;
@@ -18,6 +18,7 @@ use DateTimeInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\UnitOfWork;
@@ -432,7 +433,12 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
return;
}
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
try {
$cfName = $cfv->getCustomField()->getName();
} catch (EntityNotFoundException) {
return;
}
$fieldName = 'customField:'.$cfName;
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
@@ -30,8 +30,9 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
$constraint = $this->detectConstraintName($exception);
$error = match ($constraint) {
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
default => 'Un élément avec cette valeur existe déjà.',
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
'uniq_machine_name_site' => 'Une machine avec ce nom existe déjà sur ce site.',
default => 'Un élément avec cette valeur existe déjà.',
};
$event->setResponse(new JsonResponse(
+7 -2
View File
@@ -7,6 +7,7 @@ namespace App\Service;
use App\Entity\Composant;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use Doctrine\ORM\EntityNotFoundException;
class ReferenceAutoGenerator
{
@@ -48,8 +49,12 @@ class ReferenceAutoGenerator
/** @var CustomFieldValue $cfv */
foreach ($entity->getCustomFieldValues() as $cfv) {
$normalized = mb_strtoupper(trim($cfv->getValue()));
$map[$cfv->getCustomField()->getName()] = $normalized;
try {
$name = $cfv->getCustomField()->getName();
} catch (EntityNotFoundException) {
continue;
}
$map[$name] = mb_strtoupper(trim($cfv->getValue()));
}
return $map;
+82
View File
@@ -134,6 +134,88 @@ class MachineTest extends AbstractApiTestCase
$this->assertResponseStatusCodeSame(422);
}
public function testSameNameOnDifferentSitesIsAllowed(): void
{
$siteA = $this->createSite('Usine A');
$siteB = $this->createSite('Usine B');
$this->createMachine('Pompe', $siteA);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machines', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Pompe',
'site' => self::iri('sites', $siteB->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['name' => 'Pompe']);
}
public function testSameNameOnSameSiteIsRejected(): void
{
$site = $this->createSite('Usine');
$this->createMachine('Pompe', $site);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machines', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Pompe',
'site' => self::iri('sites', $site->getId()),
],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
],
]);
}
public function testRenameToExistingNameOnSameSiteIsRejected(): void
{
$site = $this->createSite('Usine');
$this->createMachine('Pompe', $site);
$other = $this->createMachine('Moteur', $site);
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('machines', $other->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'Pompe'],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
],
]);
}
public function testMoveToSiteWhereNameExistsIsRejected(): void
{
$siteA = $this->createSite('Usine A');
$siteB = $this->createSite('Usine B');
$this->createMachine('Pompe', $siteB);
$machine = $this->createMachine('Pompe', $siteA);
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('machines', $machine->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => self::iri('sites', $siteB->getId())],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
],
]);
}
public function testGetStructureEndpoint(): void
{
$machine = $this->createMachine('Machine structure');