Compare commits

..

10 Commits

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

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

- front : useEntityDocuments ne charge plus les documents pour un node
  pending (refreshDocuments + ensureDocumentsLoaded) + test
- back : migration Version20260529150000 réparant les 2 FK CASCADE vers
  pieces (fk_mpl_piece, fk_cfv_piece) jamais appliquées par
  Version20260528090000, avec nettoyage des orphelins (1 mpl + 3 cfv)
2026-05-29 16:10:43 +02:00
gitea-actions d5361ac3ec chore : bump version to v1.9.45
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 34s
2026-05-29 13:49:59 +00:00
Matthieu 477295c400 docs(claude) : frontend dans le même repo (plus de submodule)
Auto Tag Develop / tag (push) Successful in 9s
2026-05-29 15:49:49 +02:00
gitea-actions 22dddb73bd chore : bump version to v1.9.44
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 35s
2026-05-29 13:48:07 +00:00
Matthieu cb49c69662 fix(search) : préserver la recherche des listes au retour et ignorer les requêtes annulées
Auto Tag Develop / tag (push) Successful in 58s
- DetailHeader / MachineDetailHeader : le bouton Retour utilise router.back()
  (restaure l'URL précédente avec la query ?q=...) avec fallback sur le chemin
  nu si pas d'historique applicatif. Corrige la perte de recherche/tri/pagination
  au retour depuis une page détail (composants, produits, pièces, machines).
- ManagementView : détecte l'annulation via controller.signal.aborted au lieu de
  error.name (ofetch encapsule l'AbortError dans une FetchError), supprimant le
  toast d'erreur affiché lors d'une nouvelle recherche.
2026-05-29 15:47:06 +02:00
10 changed files with 424 additions and 274 deletions
+84 -221
View File
@@ -1,9 +1,7 @@
# CLAUDE.md — Inventory Project # CLAUDE.md — Inventory Project
## 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. **Monorepo** : backend Symfony + frontend Nuxt (`frontend/`) dans le **même dépôt git** (plus de submodule). Un seul commit/push couvre backend + frontend.
## Stack ## Stack
@@ -15,269 +13,134 @@ Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
| Frontend | Nuxt (SPA, SSR off) | 4 | | Frontend | Nuxt (SPA, SSR off) | 4 |
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 | | UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 |
| CSS | TailwindCSS 4 + DaisyUI 5 | | | CSS | TailwindCSS 4 + DaisyUI 5 | |
| Auth | Session-based (cookies, pas JWT) | | | Auth | Session-based (cookies, **pas JWT**) | |
| Containers | Docker Compose | | | Containers | Docker Compose | |
## Glossaire Métier ## Documentation détaillée (lire à la demande, ne pas dupliquer ici)
Voir `docs/GLOSSAIRE_METIER.md` — glossaire complet du domaine métier (concepts, workflows utilisateur, correspondance métier↔code). À consulter pour comprendre le "pourquoi" derrière le code.
Vu la complexité du projet, le détail vit dans `docs/` — y aller plutôt que de deviner :
- **`docs/FONCTIONNEMENT.md`** — le métier : à quoi sert l'app, entités, ModelType/skeleton, cycle de vie, rôles, fonctionnalités clés.
- **`docs/GLOSSAIRE_METIER.md`** — glossaire complet, correspondance métier ↔ code (le « pourquoi »).
- **`docs/BACKEND.md`** — catalogue backend complet : toutes les entités, **tous les controllers** (routes), audit, services, migrations, auth, rôles.
- **`docs/FRONTEND.md`** — catalogue frontend : composables, composants, useApi, IRIs, content-types, auth, style.
- **`docs/REVIEW_ARCHITECTURE.md`** — top 10 des sources de complexité et effets de bord (God controllers, canaux cachés, doubles flush…). **À consulter avant tout refacto.**
## Project Structure ## Project Structure
``` ```
Inventory/ # Backend Symfony (repo principal) Inventory/ # Backend Symfony (repo principal)
├── src/Entity/ # Entités Doctrine (annotations PHP 8 attributes) ├── src/Entity/ (+ Trait/) # Entités Doctrine (attributs PHP 8), CuidEntityTrait
│ └── Trait/ # CuidEntityTrait (génération d'ID CUID) ├── src/Controller/ # Controllers custom (session, comments, audit, structure…)
├── src/Controller/ # Controllers custom (session, comments, audit…) ├── src/EventSubscriber/ # Audit (onFlush) + sync/contraintes
├── src/EventSubscriber/ # Audit subscribers (onFlush) ├── src/Service/ (+ Sync/) # Services métier (sync, conversion, storage, versions…)
├── src/Service/ # Services métier (sync, conversion, storage…) ├── src/Enum/ src/DTO/ src/Filter/ src/Command/
├── src/Enum/ # Enums PHP (DocumentType, ModelCategory) ├── config/ migrations/ docker/ scripts/ fixtures/ tests/
├── src/DTO/ # Data Transfer Objects (sync workflow) ├── makefile VERSION # VERSION = source unique de version (semver)
── src/Filter/ # Filtres API Platform custom ── frontend/ # ← Frontend Nuxt (MÊME repo, pas un submodule)
├── src/Command/ # Commandes Symfony CLI (compress-pdf, create-profile…) └── app/{pages,components,composables,shared,middleware,services}/
├── config/ # Config Symfony
├── migrations/ # Migrations Doctrine (raw SQL PostgreSQL)
├── docker/ # Dockerfile + .env.docker
├── scripts/ # release.sh, normalize-dump.py
├── fixtures/ # SQL fixtures
├── tests/ # PHPUnit
├── pre-commit, commit-msg # Git hooks
├── makefile # Commandes Docker/dev
├── VERSION # Source unique de version (semver)
├── frontend/ # ← SUBMODULE GIT (repo séparé)
│ ├── app/pages/ # Pages Nuxt (file-based routing)
│ ├── app/components/ # Composants Vue (auto-imported)
│ ├── app/composables/ # Composables Vue
│ ├── app/shared/ # Types, utils, validation
│ ├── app/middleware/ # Auth middleware global
│ └── app/services/ # Service layer (wrappers useApi)
``` ```
## Key Commands ## Key Commands
```bash ```bash
# Docker # Docker
make start # Démarrer les containers make start / make stop # Démarrer / arrêter les containers
make stop # Arrêter make shell # Shell interactif (nécessite un TTY)
make shell # Shell interactif (nécessite un TTY) make install # Install complet (composer + npm + build)
make install # Install complet (composer + npm + build)
# Backend # Backend
make test # PHPUnit (tous les tests) make test # PHPUnit (tous)
make test FILES=tests/Api/Entity/MachineTest.php # Un test spécifique make test FILES=tests/Api/Entity/MachineTest.php # Un test
make php-cs-fixer-allow-risky # Linter PHP (cs-fixer) make test-setup # Créer/MAJ le schéma de test
make php-cs-fixer-allow-risky # Linter PHP
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate
# Frontend (dans frontend/) # Frontend (dans frontend/)
npm run dev # Dev server (port 3001) npm run dev # Dev server (port 3001)
npm run build # Build production npm run lint:fix # ESLint fix
npm run lint:fix # ESLint fix npx nuxi typecheck # TypeScript check (0 erreur attendu)
npx nuxi typecheck # TypeScript check (0 errors attendu)
# Database / Fixtures # Database / Fixtures
make db-reset # Reset database (drop + recreate schema) make db-reset # Reset DB (drop + recreate schema)
make fixtures-dump # Dump la DB vers fixtures/data.sql make fixtures-reset # Reset DB + recharger fixtures SQL
make fixtures-load # Charger les fixtures SQL (désactive FK) make import-data # Importer les dumps SQL normalisés
make fixtures-reset # Reset DB + recharger fixtures make cache-clear
make import-data # Importer les dumps SQL normalisés
make cache-clear # Clear cache Symfony
# Import fournisseurs (customer.json → Constructeur + ConstructeurCategorie + ConstructeurTelephone) # Import fournisseurs (non destructif : find-or-create par nom normalisé)
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs # dry-run (par défaut) docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs # dry-run
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs --force # applique docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs --force # applique
# Non destructif : find-or-create par nom normalisé, ne change jamais un ID existant, n'ajoute que les téléphones/catégories manquants
# Release # Release
./scripts/release.sh patch # Bump patch version (ou minor/major) ./scripts/release.sh patch # Bump version (patch/minor/major)
``` ```
## Git Conventions ## Git Conventions
### Branches - **Branches** : `master` (prod), `develop` (cible des PR), `feat/* fix/* refactor/*`.
- `master` — production - **Commit** (enforced par hook) : `<type>(<scope>) : <message>`**espace obligatoire autour du `:`**. Types : `build chore ci docs feat fix perf refactor revert style test wip`.
- `develop` — branche principale de dev (cible des PR) - Ex : `feat(auth) : add login page`, `fix(machines) : prevent null crash`
- `feat/xxx`, `fix/xxx`, `refactor/xxx` — branches de travail - **Pre-commit hook** : php-cs-fixer + PHPUnit (bloque si échec).
- **Workflow commit** : backend + frontend = **un seul commit/push** depuis la racine (pas de submodule). Le hook étant lent, committer avec `git commit --no-verify`. Push rejeté → `git pull --rebase` puis `git push`.
- **Sync master ↔ develop** : `git checkout master && git merge develop && git push` puis revenir sur `develop`.
### Commit Message Format (enforced by hook) ## Pièges & patterns non-évidents
```
<type>(<scope optionnel>) : <message>
```
**Espace obligatoire autour du `:`**. Types autorisés (minuscules) :
`build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`, `wip`
Exemples : > Le catalogue complet est dans `docs/BACKEND.md` / `docs/FRONTEND.md`. Ci-dessous **uniquement** ce qui n'est pas évident en lisant le code.
- `feat(auth) : add login page`
- `fix(machines) : prevent null crash on skeleton creation`
### Pre-commit Hook ### Backend
1. php-cs-fixer sur les fichiers PHP stagés - **IDs CUID** : strings `'cl' + bin2hex(random_bytes(12))`, **pas** d'auto-increment.
2. PHPUnit — bloque le commit si tests échouent - **Lifecycle** : `#[ORM\HasLifecycleCallbacks]` + `PrePersist`/`PreUpdate` pour `createdAt`/`updatedAt`.
- **Sécurité** : `security: "is_granted('ROLE_...')"` sur chaque opération API Platform. Hiérarchie : `ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER`.
### Submodule Workflow - **Audit** : subscribers Doctrine `onFlush` (diff + snapshot complet).
Le frontend est un submodule git. Lors d'un commit frontend : - **Migrations** : raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` (idempotence).
1. Commit dans `frontend/` d'abord - **Constructeur (Fournisseur)** : collection `telephones` (1-N, cascade/orphanRemoval) + `categories` (M2M, table `constructeur_categories`). ⚠️ L'adder M2M est `addCategory()`/`removeCategory()` (l'inflector singularise `categories``category`), **pas** `addCategorie`. Groupes API `constructeur:read` / `constructeur:write`.
2. Commit dans le repo principal pour mettre à jour le pointeur submodule - **Normalisation slots/skeleton** : les anciennes colonnes JSON `structure`/`productIds` sont remplacées par des tables relationnelles — slots réels (`ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot`, `PieceProductSlot`) vs définitions ModelType (`SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`).
3. Push les deux repos - **Custom Fields** : Composants/Pièces/Produits → définitions dans les `Skeleton*Requirement` du ModelType (clé `customFields` JSON) ; Machines → entités `CustomField` liées par `machineId` FK (pas de ModelType). Les deux partagent l'entité `CustomFieldValue` pour les valeurs.
- **`MachineStructureController`** (`/api/machines/{id}/structure`, `/clone`) : source principale de données de la page détail machine (normalisation JSON manuelle). Cf. `REVIEW_ARCHITECTURE.md` (God controller).
## Architecture Backend
### Entités Principales
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `ConstructeurCategorie`, `ConstructeurTelephone`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
> **Constructeur (Fournisseur)** : possède `name`, `email`, une collection `telephones` (1-N → `ConstructeurTelephone`, cascade/orphanRemoval) et `categories` (M2M → `ConstructeurCategorie`, table `constructeur_categories`). Sérialisation API Platform via les groupes `constructeur:read` / `constructeur:write` (téléphones & catégories embarqués). ⚠️ L'adder M2M s'appelle `addCategory()`/`removeCategory()` (l'inflector singularise `categories` → `category`), pas `addCategorie`. `ConstructeurCategorie` et `ConstructeurTelephone` sont aussi des `ApiResource` à part entière (`/api/constructeur_categories`, `/api/constructeur_telephones`).
#### Entités de normalisation (slots & skeleton requirements)
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
- **Slots composant** (données réelles d'un composant) : `ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot`
- **Slots pièce** (données réelles d'une pièce) : `PieceProductSlot`
- **Skeleton Requirements** (définitions du ModelType) : `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
### Patterns
- **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
- **ORM** : Attributs PHP 8 (`#[ORM\Column(...)]`, `#[Groups([...])]`)
- **Lifecycle** : `#[ORM\HasLifecycleCallbacks]` avec `PrePersist`/`PreUpdate` pour `createdAt`/`updatedAt`
- **Sécurité** : `security: "is_granted('ROLE_...')"` sur chaque opération API Platform
- **Audit** : Subscribers Doctrine `onFlush` capturent diff + snapshot complet
- **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence
### Custom Controllers (pas API Platform)
- `MachineStructureController``/api/machines/{id}/structure` (GET/PATCH), `/api/machines/{id}/clone` (POST) : hiérarchie complète machine avec normalisation JSON manuelle. Source principale de données pour la page détail machine.
- `MachineCustomFieldsController``/api/machines/{id}/add-custom-fields` (POST) : initialise les CustomFieldValue manquants pour une machine.
- `CustomFieldValueController``/api/custom-fields/values/*` : CRUD + upsert pour les valeurs de champs perso.
- `ComposantPieceSlotController``/api/composant-piece-slots/{id}` (PATCH) : mise à jour des slots pièce d'un composant.
- `ComposantProductSlotController``/api/composant-product-slots/{id}` (PATCH) : mise à jour des slots produit d'un composant.
- `ComposantSubcomponentSlotController``/api/composant-subcomponent-slots/{id}` (PATCH) : mise à jour des slots sous-composant d'un composant.
- `SessionProfileController``/api/session/profile` (GET/POST/DELETE) : auth session (login/logout/current user).
- `SessionProfilesController``/api/session/profiles` (GET) : liste des profils disponibles pour la session.
- `AdminProfileController``/api/admin/profiles` : CRUD profils, gestion rôles et mots de passe (ROLE_ADMIN).
- `CommentController``/api/comments` : création, résolution, compteur non-résolus.
- `ActivityLogController``/api/activity-logs` (GET) : journal d'activité global.
- `EntityHistoryController``/api/{entity}/{id}/history` (GET) : historique audit par entité (machines, pièces, composants, produits).
- `DocumentQueryController``/api/documents/{entity}/{id}` (GET) : documents par site/machine/composant/pièce/produit.
- `DocumentServeController``/api/documents/{id}/file|download` (GET) : servir/télécharger fichiers.
- `ModelTypeConversionController``/api/model_types/{id}/conversion-check|convert` : vérification et conversion de ModelType.
- `ModelTypeSyncController``/api/model_types/{id}/sync-preview|sync-confirm` (POST) : prévisualisation et application de sync ModelType→Composants.
- `EntityVersionController``/api/{entity}/{id}/versions` (GET), `/api/{entity}/{id}/versions/{version}/restore` (POST) : historique de versions numérotées et restauration.
- `HealthCheckController``/api/health` (GET) : health check.
### Custom Fields — Architecture
- **Composants/Pièces/Produits** : définitions dans les entités `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` du ModelType (anciennement JSON `structure`, normalisé en tables relationnelles). Les custom fields de ces entités sont définis dans `customFields` JSON sur chaque Skeleton*Requirement.
- **Machines** : définitions = entités `CustomField` liées directement via `machineId` FK (pas de ModelType)
- Les deux partagent la même entité `CustomFieldValue` pour stocker les valeurs
### Enums (`src/Enum/`)
- `DocumentType` — types de documents (photo, schéma, facture, etc.)
- `ModelCategory` — catégories de ModelType
### Services (`src/Service/`)
- `ModelTypeSyncService` — synchronise les skeleton requirements d'un ModelType vers les composants existants
- `ModelTypeCategoryConversionService` — conversion de catégorie d'un ModelType
- `SkeletonStructureService` — gestion de la structure skeleton (requirements)
- `DocumentStorageService` — stockage et gestion des fichiers documents
- `PdfCompressorService` — compression des PDFs uploadés
- `EntityVersionService` — gestion des versions numérotées (snapshot, restore) pour machines, pièces, composants, produits
- `ReferenceAutoGenerator` — génération automatique de références pour pièces et composants à partir de formules ModelType
- `src/Service/Sync/` — stratégies de sync par type de slot (tagged `app.sync_strategy`)
### DTOs (`src/DTO/`)
- `SyncConfirmation`, `SyncPreviewResult`, `SyncExecutionResult` — objets de transfert pour le workflow de sync ModelType
### Filters (`src/Filter/`)
- `MultiSearchFilter` — filtre API Platform pour recherche OR sur plusieurs champs (ex: name + reference)
### EventSubscribers notables (non-audit)
- `PieceProductSyncSubscriber` — sync automatique des PieceProductSlots
- `UniqueConstraintSubscriber` — traduit les erreurs de contrainte unique PG en messages utilisateur lisibles
- `ReferenceAutoSubscriber` — recalcule les références auto des pièces/composants quand les CustomFieldValues changent (onFlush)
### Rôles (hiérarchie)
```
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
```
### PostgreSQL — ATTENTION ### PostgreSQL — ATTENTION
- Les noms de colonnes sont **TOUJOURS EN MINUSCULES** dans PG - Noms de colonnes **TOUJOURS EN MINUSCULES** en PG. Doctrine camelCase (`typePieceId`) → PG `typepieceid`. Le **SQL brut doit être lowercase**.
- Doctrine utilise camelCase (`typePieceId`) mais PG stocke `typepieceid` - Tables de jointure M2M : colonnes `a` et `b` (ex : `_piececonstructeurs`).
- Le SQL brut doit utiliser les noms lowercase
- Tables de jointure many-to-many : colonnes `a` et `b` (ex: `_piececonstructeurs`)
## Architecture Frontend ### Frontend
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`.
### Patterns - **Communication composants** : Props + Events uniquement (**pas** de provide/inject).
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)` - **API** : `useApi.ts` wrappe fetch avec `credentials: 'include'`. ⚠️ `useApi()` **préfixe déjà** `/api` → appeler **sans** `/api` au début. Ex : `api.get('/custom-fields/names')` **et PAS** `'/api/custom-fields/names'` (sinon 404 sur `/api/api/...`).
- **Communication composants** : Props + Events uniquement (pas de provide/inject) - **Content-Type** : `application/ld+json` (POST/PUT), `application/merge-patch+json` (PATCH).
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session - **Auth** : `useProfileSession` + middleware global `profile.global.ts`. Permissions : `usePermissions.ts` (miroir de la hiérarchie backend).
- **⚠️ Préfixe `/api`** : `useApi()` **prepend déjà** `apiBaseUrl` (= `/api` par défaut, cf. `nuxt.config.ts`). Les appels doivent donc utiliser des chemins **sans** `/api` au début. Ex : `api.get('/custom-fields/names')` et **PAS** `api.get('/api/custom-fields/names')` (sinon 404 sur `/api/api/...`). - **Classes DaisyUI** : `input input-bordered input-sm md:input-md` (idem textarea/select/btn, `btn-primary`).
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`
- **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client
- **Auto-imports** : Nuxt auto-importe composants (`components/`) et composables (`composables/`)
### DaisyUI Classes
- Input : `input input-bordered input-sm md:input-md`
- Textarea : `textarea textarea-bordered textarea-sm md:textarea-md`
- Select : `select select-bordered select-sm md:select-md`
- Button : `btn btn-sm md:btn-md btn-primary`
## Règles Importantes ## Règles Importantes
### CLAUDE.md — Maintenance obligatoire ### Avant de modifier du code
- **Toujours consulter** ce fichier en début de conversation pour respecter les conventions 1. **Lire le fichier** avant de l'éditer.
- **Mettre à jour** ce fichier quand une nouvelle convention, pattern ou décision architecturale est établie 2. **Reproduire le pattern existant** (noms, indentation, structure).
- **Utiliser comme source de vérité** pour les commandes, patterns et règles du projet 3. **Vérifier backend ET frontend** — un changement peut impacter les deux (même repo).
### 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
### 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`
2. Frontend : `npm run lint:fix` puis `npx nuxi typecheck` si fichiers TS modifiés 2. Frontend TS : `npm run lint:fix` puis `npx nuxi typecheck`
### Ne jamais faire ### Ne jamais faire
- Ajouter des features non demandées, du code mort, ou des abstractions prématurées - Features non demandées, code mort, abstractions prématurées
- Utiliser `provide/inject` le codebase utilise Props + Events - `provide/inject` (le code utilise Props + Events) · JWT/tokens (auth session-based)
- Utiliser JWT/tokens — l'auth est session-based - SQL en camelCase (PG = lowercase)
- Écrire du SQL avec des noms camelCase — PostgreSQL = lowercase - Committer sans demande explicite · force push sans confirmation · modifier la config git
- Committer sans que l'utilisateur le demande explicitement
- Force push sans confirmation explicite
- Modifier la config git
### Submodule — Synchronisation ### Maintenir ce fichier
Quand les branches `master` et `develop` divergent sur l'un des deux repos, **toujours les synchroniser** : Mettre à jour quand une nouvelle convention/pattern/décision archi est établie. Source de vérité pour commandes, pièges et règles ; le **détail** descriptif va dans `docs/`.
- 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)
## Tests ## Tests
### Stack de test - **PHPUnit 12** + **API Platform Test** (`ApiTestCase`), env `test`, même PG.
- **PHPUnit 12** + **API Platform Test** (`ApiTestCase`) - **DAMA DoctrineTestBundle** : chaque test wrappé en transaction + rollback auto → **ne PAS** faire de TRUNCATE/cleanup en `tearDown`.
- **DAMA DoctrineTestBundle** — wrappe chaque test dans une transaction avec rollback automatique (pas de TRUNCATE) - Hériter de `AbstractApiTestCase` (helpers auth + factories `create*()`).
- Base de test : même PG, env `test` - Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`.
### Commandes
Voir section "Key Commands". Commande additionnelle :
```bash
make test-setup # Créer/mettre à jour le schéma test
```
### Pattern de test
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`, `createPieceProductSlot()`, `createConstructeurCategorie()`, `createConstructeurTelephone()`
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
## URLs Locales ## URLs Locales
- API Symfony : `http://localhost:8081/api` - API Symfony : `http://localhost:8081/api` · Nuxt dev : `http://localhost:3001`
- Nuxt dev : `http://localhost:3001` - Adminer : `http://localhost:5050` · PG direct : `localhost:5433` (user/pass `root`, db `inventory`)
- Adminer (PG) : `http://localhost:5050`
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
## Delegation Codex ## Délégation Codex
Pour les tâches mécaniques (tests, boilerplate, renommages, refacto répétitif), déléguer à Codex via le plugin `codex` (junior rapide/pas cher). Garder Claude pour la réflexion, l'architecture et la vérification (senior). Meilleur ratio qualité/crédits.
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
C'est le meilleur ratio qualite/credits.
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '1.9.43' app.version: '1.9.48'
+15 -6
View File
@@ -15,10 +15,10 @@
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" /> <IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
{{ isEditMode ? 'Voir détails' : 'Modifier' }} {{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button> </button>
<NuxtLink :to="backDestination" class="btn btn-ghost btn-sm md:btn-md"> <button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" /> <IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
{{ backLabel }} {{ backLabel }}
</NuxtLink> </button>
</div> </div>
</div> </div>
</template> </template>
@@ -29,6 +29,7 @@ import IconLucideEye from '~icons/lucide/eye'
import IconLucideArrowLeft from '~icons/lucide/arrow-left' import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const route = useRoute() const route = useRoute()
const router = useRouter()
const props = defineProps<{ const props = defineProps<{
title: string title: string
@@ -43,12 +44,20 @@ defineEmits<{
'toggle-edit': [] 'toggle-edit': []
}>() }>()
const backDestination = computed(() => { // Retour : on revient à l'URL précédente pour préserver l'état de la liste
// (recherche, tri, pagination persistés en query params). Fallback sur le
// backLink si pas d'historique applicatif (accès direct, refresh, lien partagé).
const goBack = () => {
if (route.query.from === 'machine' && route.query.machineId) { if (route.query.from === 'machine' && route.query.machineId) {
return `/machine/${route.query.machineId}` router.push(`/machine/${route.query.machineId}`)
return
} }
return props.backLink if (window.history.state?.back) {
}) router.back()
return
}
router.push(props.backLink)
}
const backLabel = computed(() => { const backLabel = computed(() => {
if (route.query.from === 'machine') { if (route.query.from === 'machine') {
@@ -4,7 +4,7 @@
<ul> <ul>
<!-- First crumb (always visible) --> <!-- First crumb (always visible) -->
<li> <li>
<NuxtLink :to="crumbs[0].path" class="text-base-content/60 hover:text-primary transition-colors"> <NuxtLink :to="crumbs[0].to" class="text-base-content/60 hover:text-primary transition-colors">
{{ crumbs[0].label }} {{ crumbs[0].label }}
</NuxtLink> </NuxtLink>
</li> </li>
@@ -18,7 +18,7 @@
:key="i" :key="i"
class="hidden sm:list-item" class="hidden sm:list-item"
> >
<NuxtLink :to="crumb.path" class="text-base-content/60 hover:text-primary transition-colors"> <NuxtLink :to="crumb.to" class="text-base-content/60 hover:text-primary transition-colors">
{{ crumb.label }} {{ crumb.label }}
</NuxtLink> </NuxtLink>
</li> </li>
@@ -32,15 +32,40 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { RouteLocationRaw } from 'vue-router'
import { useListQueryMemory } from '~/composables/useListQueryMemory'
interface Crumb { interface Crumb {
label: string label: string
path: string to: RouteLocationRaw
} }
const route = useRoute() const route = useRoute()
const { remember, recall } = useListQueryMemory()
// Routes-listes dont la recherche / tri / pagination doit survivre à une
// navigation par fil d'Ariane ou menu (qui ne passe pas par l'historique).
const LIST_PATHS = ['/machines', '/catalogues/composants', '/catalogues/pieces', '/catalogues/produits']
// On enregistre la query courante dès qu'on est sur une route-liste (et à chaque
// changement de recherche/tri/pagination, qui modifie fullPath).
watch(
() => route.fullPath,
() => {
if (LIST_PATHS.includes(route.path)) remember(route.path, route.query)
},
{ immediate: true },
)
// Cible d'un crumb pointant vers une liste : on réinjecte la dernière query
// mémorisée pour restaurer l'état, sinon chemin nu (liste neuve).
const listTo = (path: string): RouteLocationRaw => {
const query = recall(path)
return query && Object.keys(query).length > 0 ? { path, query } : path
}
const crumbs = computed<Crumb[]>(() => { const crumbs = computed<Crumb[]>(() => {
const result: Crumb[] = [{ label: 'Accueil', path: '/' }] const result: Crumb[] = [{ label: 'Accueil', to: '/' }]
const path = route.path const path = route.path
// Home page — no breadcrumb // Home page — no breadcrumb
@@ -48,88 +73,88 @@ const crumbs = computed<Crumb[]>(() => {
// Machine context from query param (when navigating from a machine detail page) // Machine context from query param (when navigating from a machine detail page)
if (route.query.from === 'machine' && route.query.machineId) { if (route.query.from === 'machine' && route.query.machineId) {
result.push({ label: 'Parc machines', path: '/machines' }) result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Machine', path: `/machine/${route.query.machineId}` }) result.push({ label: 'Machine', to: `/machine/${route.query.machineId}` })
} }
// Machines // Machines
if (path === '/machines') { if (path === '/machines') {
result.push({ label: 'Parc machines', path: '/machines' }) result.push({ label: 'Parc machines', to: listTo('/machines') })
} else if (path.startsWith('/machine/') && !route.query.from) { } else if (path.startsWith('/machine/') && !route.query.from) {
result.push({ label: 'Parc machines', path: '/machines' }) result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Machine', path }) result.push({ label: 'Machine', to: path })
} }
// Catalogs // Catalogs
else if (path.startsWith('/catalogues/composants')) { else if (path.startsWith('/catalogues/composants')) {
result.push({ label: 'Composants', path: '/catalogues/composants' }) result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
} else if (path.startsWith('/catalogues/pieces')) { } else if (path.startsWith('/catalogues/pieces')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' }) result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
} else if (path.startsWith('/catalogues/produits')) { } else if (path.startsWith('/catalogues/produits')) {
result.push({ label: 'Produits', path: '/catalogues/produits' }) result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
} }
// Entity detail pages (when NOT from machine context) // Entity detail pages (when NOT from machine context)
else if (path.startsWith('/component/') && !route.query.from) { else if (path.startsWith('/component/') && !route.query.from) {
result.push({ label: 'Composants', path: '/catalogues/composants' }) result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Composant', path }) result.push({ label: 'Composant', to: path })
} else if (path.startsWith('/piece/') && !route.query.from) { } else if (path.startsWith('/piece/') && !route.query.from) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' }) result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Pièce', path }) result.push({ label: 'Pièce', to: path })
} else if (path.startsWith('/product/') && !route.query.from) { } else if (path.startsWith('/product/') && !route.query.from) {
result.push({ label: 'Produits', path: '/catalogues/produits' }) result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Produit', path }) result.push({ label: 'Produit', to: path })
} }
// Entity detail pages WITH machine context — add entity as last crumb // Entity detail pages WITH machine context — add entity as last crumb
else if (path.startsWith('/component/') && route.query.from === 'machine') { else if (path.startsWith('/component/') && route.query.from === 'machine') {
result.push({ label: 'Composant', path }) result.push({ label: 'Composant', to: path })
} else if (path.startsWith('/piece/') && route.query.from === 'machine') { } else if (path.startsWith('/piece/') && route.query.from === 'machine') {
result.push({ label: 'Pièce', path }) result.push({ label: 'Pièce', to: path })
} else if (path.startsWith('/product/') && route.query.from === 'machine') { } else if (path.startsWith('/product/') && route.query.from === 'machine') {
result.push({ label: 'Produit', path }) result.push({ label: 'Produit', to: path })
} }
// Admin pages // Admin pages
else if (path.startsWith('/sites')) { else if (path.startsWith('/sites')) {
result.push({ label: 'Sites', path: '/sites' }) result.push({ label: 'Sites', to: '/sites' })
} else if (path.startsWith('/constructeurs')) { } else if (path.startsWith('/constructeurs')) {
result.push({ label: 'Fournisseurs', path: '/constructeurs' }) result.push({ label: 'Fournisseurs', to: '/constructeurs' })
} else if (path.startsWith('/activity-log')) { } else if (path.startsWith('/activity-log')) {
result.push({ label: 'Journal d\'activité', path: '/activity-log' }) result.push({ label: 'Journal d\'activité', to: '/activity-log' })
} else if (path.startsWith('/admin')) { } else if (path.startsWith('/admin')) {
result.push({ label: 'Administration', path: '/admin' }) result.push({ label: 'Administration', to: '/admin' })
} else if (path.startsWith('/documents')) { } else if (path.startsWith('/documents')) {
result.push({ label: 'Documents', path: '/documents' }) result.push({ label: 'Documents', to: '/documents' })
} else if (path.startsWith('/comments')) { } else if (path.startsWith('/comments')) {
result.push({ label: 'Commentaires', path: '/comments' }) result.push({ label: 'Commentaires', to: '/comments' })
} }
// Category pages // Category pages
else if (path.startsWith('/component-category')) { else if (path.startsWith('/component-category')) {
result.push({ label: 'Composants', path: '/catalogues/composants' }) result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Catégorie', path }) result.push({ label: 'Catégorie', to: path })
} else if (path.startsWith('/piece-category')) { } else if (path.startsWith('/piece-category')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' }) result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Catégorie', path }) result.push({ label: 'Catégorie', to: path })
} else if (path.startsWith('/product-category')) { } else if (path.startsWith('/product-category')) {
result.push({ label: 'Produits', path: '/catalogues/produits' }) result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Catégorie', path }) result.push({ label: 'Catégorie', to: path })
} }
// Create pages // Create pages
else if (path.startsWith('/pieces/create')) { else if (path.startsWith('/pieces/create')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' }) result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Nouvelle pièce', path }) result.push({ label: 'Nouvelle pièce', to: path })
} else if (path.startsWith('/component/create')) { } else if (path.startsWith('/component/create')) {
result.push({ label: 'Composants', path: '/catalogues/composants' }) result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Nouveau composant', path }) result.push({ label: 'Nouveau composant', to: path })
} else if (path.startsWith('/product/create')) { } else if (path.startsWith('/product/create')) {
result.push({ label: 'Produits', path: '/catalogues/produits' }) result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Nouveau produit', path }) result.push({ label: 'Nouveau produit', to: path })
} else if (path === '/machines/new') { } else if (path === '/machines/new') {
result.push({ label: 'Parc machines', path: '/machines' }) result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Nouvelle machine', path }) result.push({ label: 'Nouvelle machine', to: path })
} }
return result return result
@@ -36,10 +36,10 @@
> >
<IconLucidePrinter class="w-4 h-4" aria-hidden="true" /> <IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
</button> </button>
<NuxtLink to="/machines" class="btn btn-ghost btn-sm md:btn-md"> <button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" /> <IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
Parc machines Parc machines
</NuxtLink> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -52,6 +52,18 @@ import IconLucidePrinter from '~icons/lucide/printer'
import IconLucideArrowLeft from '~icons/lucide/arrow-left' import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const router = useRouter()
// Retour : revient à l'URL précédente pour préserver la recherche/filtres du
// parc machines (persistés en query params). Fallback vers /machines si pas
// d'historique applicatif (accès direct, refresh, lien partagé).
const goBack = () => {
if (window.history.state?.back) {
router.back()
return
}
router.push('/machines')
}
const props = defineProps<{ const props = defineProps<{
title: string title: string
@@ -281,7 +281,10 @@ const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}
limit.value = response.limit limit.value = response.limit
} }
catch (error: unknown) { catch (error: unknown) {
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return // Requête annulée volontairement (nouvelle recherche / démontage) : pas une
// vraie erreur. On teste le signal car ofetch encapsule l'AbortError dans
// une FetchError, donc error.name n'est pas fiable.
if (controller.signal.aborted) return
showError(extractErrorMessage(error)) showError(extractErrorMessage(error))
} }
finally { finally {
@@ -56,7 +56,9 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
// CRUD operations // CRUD operations
const refreshDocuments = async () => { const refreshDocuments = async () => {
const e = entity() const e = entity()
if (!e?.id || e._structurePiece) return // Pending / category-only nodes carry the link id (not a real entity id) and
// have no backing piece/composant — never request documents for them.
if (!e?.id || e._structurePiece || e.pendingEntity) return
loadingDocuments.value = true loadingDocuments.value = true
try { try {
const result: any = await loadDocumentsFn(e.id, { updateStore: false }) const result: any = await loadDocumentsFn(e.id, { updateStore: false })
@@ -70,7 +72,8 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
} }
const ensureDocumentsLoaded = async () => { const ensureDocumentsLoaded = async () => {
if (documentsLoaded.value || !entity()?.id) return const e = entity()
if (documentsLoaded.value || !e?.id || e.pendingEntity) return
await refreshDocuments() await refreshDocuments()
} }
@@ -0,0 +1,17 @@
import { reactive } from 'vue'
import type { LocationQuery } from 'vue-router'
// Singleton module-level : mémorise la dernière query (recherche / tri /
// pagination / filtres) vue sur chaque route-liste. Permet aux navigations qui
// ne passent PAS par l'historique du navigateur (fil d'Ariane, menu) de
// restaurer l'état de la liste, là où router.back() le ferait pour le bouton
// Retour. SPA only (SSR off) — pas de fuite d'état entre requêtes.
const memory = reactive<Record<string, LocationQuery>>({})
export function useListQueryMemory() {
const remember = (path: string, query: LocationQuery) => {
memory[path] = { ...query }
}
const recall = (path: string): LocationQuery | undefined => memory[path]
return { remember, recall }
}
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockLoadDocumentsByPiece = vi.fn()
const mockLoadDocumentsByComponent = vi.fn()
vi.mock('~/composables/useDocuments', () => ({
useDocuments: () => ({
loadDocumentsByPiece: mockLoadDocumentsByPiece,
loadDocumentsByComponent: mockLoadDocumentsByComponent,
uploadDocuments: vi.fn(),
deleteDocument: vi.fn(),
updateDocument: vi.fn(),
}),
}))
vi.mock('~/utils/documentPreview', () => ({
canPreviewDocument: () => true,
}))
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// refreshDocuments — pending / orphan entities
// ---------------------------------------------------------------------------
describe('refreshDocuments', () => {
it('does NOT load documents for a pending piece node (orphan link id is not a piece id)', async () => {
// A category-only / pending piece node: its `id` is the machinePieceLink id,
// there is no real piece behind it (pieceId is null).
const pendingNode = {
id: 'cl48179803369dd93b4a90b784', // machinePieceLink id, NOT a piece id
pieceId: null,
pendingEntity: true,
documents: [],
}
const { refreshDocuments } = useEntityDocuments({
entity: () => pendingNode,
entityType: 'piece',
})
await refreshDocuments()
expect(mockLoadDocumentsByPiece).not.toHaveBeenCalled()
})
it('loads documents for a real piece node using its piece id', async () => {
mockLoadDocumentsByPiece.mockResolvedValue({ success: true, data: [] })
const realNode = {
id: 'clrealpieceid000000000000',
pieceId: 'clrealpieceid000000000000',
pendingEntity: false,
documents: [],
}
const { refreshDocuments } = useEntityDocuments({
entity: () => realNode,
entityType: 'piece',
})
await refreshDocuments()
expect(mockLoadDocumentsByPiece).toHaveBeenCalledWith('clrealpieceid000000000000', { updateStore: false })
})
})
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Repair migration for Version20260528090000_FixPieceCascadeFKs.
*
* On some environments (prod included) that migration was recorded as executed
* but two of its six FKs to `pieces.id` never took effect:
* - machine_piece_links.pieceid (fk_mpl_piece)
* - custom_field_values.pieceid (fk_cfv_piece)
* Without them, deleting a Piece leaves orphan rows behind (a stale pieceid
* pointing to a non-existent piece), which surfaces as a "Catégorie sans item"
* ghost on the machine detail page and a 404 on /documents/piece/{id}.
*
* This migration re-applies ONLY those two missing pieces of the original one:
* snapshot orphans to audit_logs, delete them, then (re)add the FK with the
* correct ON DELETE CASCADE. Fully idempotent — safe where the FKs already exist.
*/
final class Version20260529150000_AddMissingPieceCascadeFKs extends AbstractMigration
{
public function getDescription(): string
{
return 'Repair missing CASCADE FKs to pieces on machine_piece_links and custom_field_values (orphan cleanup + fk_mpl_piece / fk_cfv_piece)';
}
public function up(Schema $schema): void
{
// =========================================================================
// 1. Audit log : snapshot des rows orphelines avant suppression.
// =========================================================================
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'machine_piece_link',
l.id,
'delete',
json_build_object(
'id', l.id,
'machineId', l.machineid,
'pieceId', l.pieceid,
'note', 'Cleaned by FK cascade repair migration (Version20260529150000) - referenced piece no longer existed'
),
NULL,
NOW()
FROM machine_piece_links l
WHERE l.pieceid IS NOT NULL
AND l.pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'custom_field_value',
v.id,
'delete',
json_build_object(
'id', v.id,
'pieceId', v.pieceid,
'note', 'Cleaned by FK cascade repair migration (Version20260529150000) - referenced piece no longer existed'
),
NULL,
NOW()
FROM custom_field_values v
WHERE v.pieceid IS NOT NULL
AND v.pieceid NOT IN (SELECT id FROM pieces)
SQL);
// =========================================================================
// 2. Nettoyage des orphelins (avant ADD CONSTRAINT, sinon PG rejette).
// =========================================================================
$this->addSql(<<<'SQL'
DELETE FROM machine_piece_links
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
DELETE FROM custom_field_values
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
SQL);
// =========================================================================
// 3. Drop des éventuelles FK existantes vers `pieces` (quel que soit leur
// nom historique), puis ADD CONSTRAINT avec le bon ON DELETE.
// =========================================================================
$this->dropFksReferencingPieces('machine_piece_links', 'pieceid');
$this->addSql(<<<'SQL'
ALTER TABLE machine_piece_links ADD CONSTRAINT fk_mpl_piece
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
SQL);
$this->dropFksReferencingPieces('custom_field_values', 'pieceid');
$this->addSql(<<<'SQL'
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_piece
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_mpl_piece');
$this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_piece');
}
/**
* Drop every FK on $table.$column that references the `pieces` table,
* regardless of its historic name. Idempotent.
*/
private function dropFksReferencingPieces(string $table, string $column): void
{
$sql = <<<SQL
DO \$\$
DECLARE
fk_name TEXT;
BEGIN
FOR fk_name IN
SELECT tc.constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON kcu.constraint_name = tc.constraint_name
AND kcu.table_schema = tc.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.table_name = '{$table}'
AND tc.constraint_type = 'FOREIGN KEY'
AND kcu.column_name = '{$column}'
AND ccu.table_name = 'pieces'
LOOP
EXECUTE format('ALTER TABLE {$table} DROP CONSTRAINT %I', fk_name);
END LOOP;
END \$\$;
SQL;
$this->addSql($sql);
}
}