Compare commits

...

18 Commits

Author SHA1 Message Date
Matthieu bfd77a1ca1 docs : review complète du projet (REVIEW.md + TICKETS.md) 2026-06-11 11:18:27 +02: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
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
21 changed files with 1877 additions and 292 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.
+434
View File
@@ -0,0 +1,434 @@
# Review complète — Projet Inventory
> Audit éducatif du projet. Chaque point explique le problème, pourquoi c'est un problème, et comment le corriger.
> Document généré le 2026-06-11 (branche `develop`, v1.9.47).
> Complément de `docs/REVIEW_ARCHITECTURE.md` (2026-03-23) — les findings d'architecture n'y sont pas dupliqués, seul leur **statut** est mis à jour ici (§4).
---
## Table des matières
1. [Sécurité](#1-sécurité)
2. [Bugs fonctionnels](#2-bugs-fonctionnels)
3. [Code mort et violations des règles projet](#3-code-mort-et-violations-des-règles-projet)
4. [Dette d'architecture — suivi REVIEW_ARCHITECTURE.md](#4-dette-darchitecture--suivi-review_architecturemd)
5. [Documentation et configuration](#5-documentation-et-configuration)
6. [Frontend et UX](#6-frontend-et-ux)
7. [CI/CD et dépendances](#7-cicd-et-dépendances)
8. [Hygiène git](#8-hygiène-git)
9. [Bonnes pratiques à retenir](#9-bonnes-pratiques-à-retenir)
---
## 1. Sécurité
### 1.1 CRITIQUE — Credentials de production commités dans `.mcp.json`
**Fichier :** `.mcp.json` (tracké dans git, **pas** dans `.gitignore`)
Le fichier contient le mot de passe du profil admin MCP de production (`X-Profile-Password: A123` pour `inventory.malio-dev.fr`) **et** un bearer token Lesstime valide (`project.malio-dev.fr`). Toute personne ayant accès au dépôt (ou à son historique, même après suppression du fichier) peut s'authentifier sur les deux systèmes de production.
**Pourquoi c'est grave :** un secret commité reste dans l'historique git pour toujours. Le token Lesstime donne accès à tout le système de gestion de projet (tâches, temps, absences). Le mot de passe `A123` est en plus trivial.
**Correction :**
1. **Révoquer/changer** les deux secrets (mot de passe du profil MCP + régénérer le token Lesstime) — c'est l'étape la plus importante, supprimer le fichier ne suffit pas.
2. `git rm --cached .mcp.json` + ajouter `.mcp.json` au `.gitignore`.
3. Conserver un `.mcp.json.example` avec des placeholders.
### 1.2 CRITIQUE — `create_test_user.php` tracké à la racine avec credentials admin en clair
**Fichier :** `create_test_user.php:16,59-61` (tracké dans git)
Script de debug qui crée un compte `ROLE_ADMIN` avec `admin@admin.com` / `admin123` (hardcodé et affiché en clair sur stdout), connexion PDO brute `root`/`root`. Posé à la racine du projet, il est embarquable dans une image de prod et exécutable partout où `vendor/` existe.
**Pourquoi c'est grave :** si ce script tourne (ou a tourné) en production, il existe un compte admin avec un mot de passe devinable en 3 essais. Les credentials sont aussi dans l'historique git.
**Correction :** supprimer le fichier du dépôt (`git rm`), vérifier **en prod** qu'aucun profil `admin@admin.com` actif n'existe. Le besoin légitime (seed d'un admin de dev) est déjà couvert par `fixtures/` ou peut devenir une commande Symfony `app:create-admin` qui demande le mot de passe en argument.
### 1.3 IMPORTANT — Mot de passe de la base de prod hardcodé dans 9 scripts trackés
**Fichiers :** `scripts/check-prod-values.php`, `scripts/fix-prod-all.php`, `scripts/restore-custom-field-values.php`, `scripts/migrate-orphaned-custom-fields.php`, `scripts/check-prod-audit-dates.php`, `scripts/check-prod-missing-piece-cfs.php`, `scripts/check-prod-orphaned-detail.php`, `scripts/fix-prod-recreate-and-migrate.php`, `scripts/verify-prod-health.php`
Neuf scripts de réparation one-shot contiennent le couple `ferme_user`/`fermerecette` en dur — des credentials de base de données de production, dans le dépôt.
**Pourquoi c'est grave :** même problème que 1.1 — secret en clair dans l'historique. En plus ces scripts contournent Doctrine et l'audit : les exécuter par erreur modifie la prod sans trace.
**Correction :** changer le mot de passe PG concerné, puis archiver/supprimer ces scripts (ils ont déjà servi). S'ils doivent rester, lire les credentials depuis l'environnement (`getenv('DATABASE_URL')`) et les déplacer dans `_archives/` (déjà gitignoré).
### 1.4 IMPORTANT — Aucune limite de taille d'upload (ni applicative, ni infra)
**Fichiers :** `src/State/DocumentUploadProcessor.php:55-116`, `src/Controller/CommentController.php:104-137`, `infra/dev/php.ini`, `infra/prod/nginx.conf`
Aucun des deux chemins d'upload ne vérifie `$file->getSize()`. Côté infra : `php.ini` ne définit ni `upload_max_filesize` ni `post_max_size` (défauts PHP : 2 Mo / 8 Mo), et `nginx.conf` prod n'a pas de `client_max_body_size` (défaut nginx : **1 Mo**).
**Pourquoi c'est un problème (double) :**
- *Fonctionnel* : en prod, tout upload > 1 Mo est probablement rejeté par nginx avec une erreur 413 brute (non gérée par le front) — alors que l'app est censée stocker des PDF techniques.
- *Sécurité* : aucune limite **choisie** n'existe ; le jour où quelqu'un monte les limites infra "pour faire passer un gros PDF", plus rien ne protège le disque (un `ROLE_VIEWER` peut uploader via les commentaires, cf. 1.9).
**Correction :** décider d'une limite métier (ex. 50 Mo), puis l'appliquer aux 3 niveaux :
1. Check applicatif `if ($file->getSize() > self::MAX_UPLOAD_BYTES)` dans les deux chemins (erreur 400 propre).
2. `upload_max_filesize = 50M` / `post_max_size = 55M` dans `infra/dev/php.ini` **et** l'image prod.
3. `client_max_body_size 55m;` dans `infra/prod/nginx.conf`.
### 1.5 MOYEN — Garde anti path-traversal incomplet dans `DocumentStorageService`
**Fichier :** `src/Service/DocumentStorageService.php:28-42`
`getAbsolutePath()` vérifie `str_contains($relativePath, '..')` puis compare `realpath()` au répertoire de stockage — mais `realpath()` renvoie `false` pour un fichier inexistant, donc le second contrôle est **sauté** dans ce cas. Un chemin absolu (`/etc/passwd`) passe le premier contrôle (pas de `..`) : `$this->storageDir.'/'.'/etc/passwd'`… ne résout pas vers `/etc/passwd`, mais un chemin via symlink dans le storage le pourrait. L'exploitation exige d'écrire `document.path` en base (pas d'input direct utilisateur), donc le risque actuel est faible — c'est du **hardening**.
**Correction :** valider sur le répertoire parent, qui existe toujours :
```php
$absolutePath = $this->storageDir.'/'.$relativePath;
$realParent = realpath(dirname($absolutePath));
if (false === $realParent || !str_starts_with($realParent.'/', realpath($this->storageDir).'/')) {
throw new RuntimeException(sprintf('Path traversal detected: "%s"', $relativePath));
}
```
### 1.6 MOYEN — Pas de protection CSRF : `SameSite=Lax` est la seule barrière
**Fichiers :** `config/packages/framework.yaml:8`, `config/packages/nelmio_cors.yaml`
L'auth est par cookie de session, et aucun endpoint d'écriture ne vérifie de token CSRF ni de header custom. La protection repose à 100 % sur `cookie_samesite: lax` (et le CORS pour les lectures cross-origin). C'est la posture courante pour une SPA en 2026, mais c'est une **défense à un seul étage** : un navigateur ancien/exotique ou une config future en sous-domaine partagé la ferait tomber. À noter : `csrfToken` existe dans `nuxt.config.ts:59` mais n'est branché nulle part (config morte, cf. 3.1).
**Correction (peu coûteuse) :** exiger un header `X-Requested-With: XMLHttpRequest` sur les méthodes non-GET du firewall `api` (un listener de 15 lignes) — un formulaire HTML cross-site ne peut pas envoyer ce header. L'ajouter dans `useApi.ts` côté front. Supprimer le `csrfToken` mort.
### 1.7 MOYEN — `session_fixation_strategy: none` désactive la protection globalement
**Fichier :** `config/packages/security.yaml:5`, mitigé par `src/Controller/SessionProfileController.php:96`
Le choix est documenté (le `migrate` par défaut casse les requêtes concurrentes de la SPA) et le login appelle bien `$session->migrate(true)` manuellement — le flux actuel est correct. Le risque est **futur** : tout nouveau chemin d'authentification (reset de mot de passe, impersonation…) n'aura pas la régénération d'ID de session, silencieusement.
**Correction :** garder le réglage, mais l'encadrer : un test fonctionnel qui vérifie que l'ID de session change au login (échouera si quelqu'un retire le `migrate(true)`), et un commentaire dans `SessionProfileController` pointant vers `security.yaml`.
### 1.8 MOYEN — MCP HTTP : mot de passe en clair dans les headers à chaque requête
**Fichier :** `src/Mcp/Security/McpHeaderAuthenticator.php:43-44`, `infra/prod/nginx-proxy.conf`
Chaque requête MCP porte `X-Profile-Password` en clair. Les headers transitent par le proxy nginx et peuvent finir dans des logs (proxy, APM, outils de debug). Le rate-limiting et le hash côté serveur sont bien faits, mais le secret circule en permanence — et l'URL configurée dans `.mcp.json` est en **`http://`** (pas de TLS).
**Correction :** passer à un token d'API dédié (longue chaîne aléatoire, stockée hashée, comparée via le hasher existant), transmis en `Authorization: Bearer` — comme le fait déjà Lesstime. Et servir `/_mcp` uniquement en HTTPS.
### 1.9 MINEUR — Création de commentaires + upload de fichiers ouverte à `ROLE_VIEWER`
**Fichier :** `src/Controller/CommentController.php:33`
Convention du projet : lecture = `ROLE_VIEWER`, écriture = `ROLE_GESTIONNAIRE`. La création de commentaire (avec pièces jointes !) est la seule écriture accessible aux viewers. Si c'est un choix métier (« tout le monde peut commenter »), OK — mais combiné à 1.4, un compte en lecture seule peut remplir le disque.
**Correction :** confirmer le choix métier et le documenter dans `docs/BACKEND.md` ; appliquer la limite de taille de 1.4 dans tous les cas.
### 1.10 MINEUR — `download()` sans les headers de protection de `serve()`
**Fichier :** `src/Controller/DocumentServeController.php:105-116`
`serve()` envoie `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox` (très bien) ; `download()` n'envoie ni l'un ni l'autre pour les fichiers disque. La disposition `attachment` protège déjà beaucoup, mais l'asymétrie est gratuite.
**Correction :** copier les deux headers dans `download()`.
### 1.11 MINEUR — Pagination max très élevée sur certaines ressources
**Fichiers :** `src/Entity/Constructeur.php:47` (2000), `src/Entity/ConstructeurCategorie.php:43` (1000), `src/Entity/Document.php:55` (500)
Un `?itemsPerPage=2000` charge 2000 entités + sérialisation en une requête. Pour des catalogues internes c'est sans doute volontaire (dropdowns sans pagination), mais c'est aussi un vecteur de charge facile.
**Correction :** vérifier ce que le front demande réellement et redescendre au besoin réel (ou documenter pourquoi 2000).
### 1.12 MINEUR — `infra/dev/.env.docker.local` tracké malgré le `.gitignore`
**Fichier :** `infra/dev/.env.docker.local:27,34`
Le fichier est dans `.gitignore` (ligne 23) **mais déjà tracké** (ajouté avant la règle — gitignore n'agit pas sur les fichiers déjà suivis). Secrets de dev faibles (`changeme_…`) : sans gravité en soi, mais le fichier est censé être local et chaque dev qui le modifie crée du diff.
**Correction :** `git rm --cached infra/dev/.env.docker.local`, fournir `infra/dev/.env.docker.local.example` (référencé par le README au passage).
---
## 2. Bugs fonctionnels
### 2.1 IMPORTANT — Fallback `http://localhost:3000` dans `useApi.ts`
**Fichier :** `frontend/app/composables/useApi.ts:18`
```ts
const API_BASE_URL = (publicConfig.apiBaseUrl as string) || 'http://localhost:3000'
```
Si `NUXT_PUBLIC_API_BASE_URL` est vide au build/runtime de prod, **tous** les appels API partent silencieusement vers `localhost:3000` (le port du dev server, qui ne sert même pas l'API). Échec garanti mais difficile à diagnostiquer.
**Correction :** fallback relatif `'/api'`… attention, `useApi` préfixe déjà `/api` lui-même — le bon fallback est donc `''` (origine courante) :
```ts
const API_BASE_URL = (publicConfig.apiBaseUrl as string) || ''
```
Même nettoyage pour `nuxt.config.ts:49` (`http://localhost/api`, valeur SSR jamais utilisée puisque `ssr: false`).
### 2.2 IMPORTANT — Uploads de prod probablement plafonnés à 1 Mo par nginx
Voir 1.4 — c'est le versant fonctionnel : sans `client_max_body_size`, nginx rejette en 413 tout body > 1 Mo, et le front n'affiche pas d'erreur claire (le toast générique de `useApi` au mieux). À tester en prod avec un PDF de 5 Mo ; si les uploads passent, c'est qu'une config existe ailleurs et il faut l'aligner dans le dépôt.
### 2.3 MOYEN — Deux mécanismes de maintenance déconnectés
**Fichiers :** `src/Controller/MaintenanceController.php:56` (flag `var/maintenance`), `infra/prod/nginx.conf:6` (flag `maintenance.on`), `infra/prod/deploy.sh:47`
Le toggle admin (`PUT /api/admin/maintenance`) écrit `var/maintenance`, lu par le middleware front — maintenance **applicative**. Le déploiement crée `maintenance.on`, lu par nginx — maintenance **infra**. Les deux coexistent volontairement mais rien ne le documente : un admin qui active la maintenance via l'UI ne bloque pas les appels API directs (le flag n'est vérifié que par le middleware front), et inversement.
**Correction :** documenter les deux niveaux dans `DEPLOY.md` ; idéalement faire vérifier le flag applicatif côté backend (listener kernel.request qui renvoie 503 pour les non-admins) plutôt que de ne compter que sur le middleware front (contournable).
### 2.4 MOYEN — Provider Symfony par `email` alors que `email` est nullable
**Fichiers :** `config/packages/security.yaml:18`, `src/Entity/Profile.php:59-61`, `src/Controller/AdminProfileController.php:66`
On peut créer un profil sans email (profils « kiosque »), mais le user provider charge par `property: email`. Ça marche aujourd'hui parce que `SessionProfileAuthenticator` charge par ID — le provider n'est jamais utilisé pour ces profils. Incohérence latente : tout futur usage du provider standard (remember-me, impersonation, commande console) cassera sur ces profils.
**Correction :** soit rendre l'email obligatoire et générer un email technique pour les kiosques, soit écrire un `UserProviderInterface` custom qui charge par id **ou** email, et le déclarer dans `security.yaml`.
---
## 3. Code mort et violations des règles projet
### 3.1 MINEUR — Config morte dans `nuxt.config.ts`
**Fichier :** `frontend/nuxt.config.ts:56-59`
`csrfToken`, `requestTimeout`, `enableDebug`, `enableAnalytics`, `logLevel` : définis, jamais consommés (seul `apiTimeout` est lu par `useApi.ts`). Du code mort en config laisse croire que des fonctionnalités existent (un lecteur pense que le CSRF est géré — il ne l'est pas, cf. 1.6).
**Correction :** supprimer ces 5 clés.
### 3.2 MINEUR — Variables JWT dans un projet 100 % session
**Fichier :** `infra/dev/.env.docker.local:28-30`
`JWT_SECRET_KEY`, `JWT_PUBLIC_KEY`, `JWT_PASSPHRASE` — copiées d'un autre projet (Lesstime/Starseed utilisent JWT, pas Inventory). Le CLAUDE.md martèle « pas JWT » ; ces variables sèment le doute.
**Correction :** supprimer les 3 lignes.
### 3.3 MOYEN — `@nuxtjs/tailwindcss` : dépendance inutilisée et conflictuelle
**Fichier :** `frontend/package.json:21`
Le projet utilise Tailwind 4 via `@tailwindcss/vite` (`nuxt.config.ts:1,64`). `@nuxtjs/tailwindcss` n'est référencé nulle part et installe son propre Tailwind **v3** dans `node_modules` — bloat + risque de résolution ambiguë.
**Correction :** `npm uninstall @nuxtjs/tailwindcss` puis vérifier `npm run build`.
### 3.4 MINEUR — Dockerfile dev pollué par un template générique
**Fichier :** `infra/dev/Dockerfile:48-53,82-100`
Blocs commentés Oracle OCI8, IMAP/Kerberos, PDO MySQL/SQLite — aucun rapport avec un projet PostgreSQL. Bruit pur.
**Correction :** supprimer les blocs commentés.
### 3.5 MINEUR — `node_modules/` orphelin à la racine backend
Pas de `package.json` à la racine, mais un `node_modules/` (untracked) y traîne, et `node_modules/` n'est pas dans le `.gitignore` racine — seul le hasard l'empêche d'être commité un jour.
**Correction :** `rm -rf node_modules/` à la racine + ajouter `/node_modules/` au `.gitignore`.
### 3.6 MINEUR — 3 fichiers utils restés en `.js` non typés
**Fichiers :** `frontend/app/utils/documentPreview.js`, `frontend/app/utils/fileIcons.js`, `frontend/app/utils/printTemplates/machineReport.js`
Importés depuis du `.ts` sans aucune sécurité de type — incohérent avec la règle « TypeScript, 0 erreur typecheck ».
**Correction :** renommer en `.ts` et typer les signatures (mécanique, bon candidat Codex).
---
## 4. Dette d'architecture — suivi REVIEW_ARCHITECTURE.md
État des 10 chantiers identifiés le 2026-03-23, vérifié ce jour : **1 corrigé sur 10**, et le God controller a grossi.
| # | Source de complexité | Statut 2026-06-11 |
|---|---------------------|-------------------|
| 1 | `smartMatch` dupliqué dans les Sync Strategies | ❌ Toujours dupliqué (`ComposantSyncStrategy.php:380`, `PieceSyncStrategy.php:244`) |
| 2 | Custom Fields : 4-6 FK nullable (polymorphisme pauvre) | ❌ Inchangé, pas de contrainte CHECK |
| 3 | Composables géants | ⚠️ Partiel : `useComponentEdit.ts` 539 LOC, `usePieceEdit.ts` 404, `useComponentCreate.ts` 366 |
| 4 | Triple duplication utils custom fields | ✅ **Corrigé** — fusionné dans `shared/utils/customFields.ts` |
| 5 | `pendingStructure` canal caché | ❌ Toujours sans `try/finally` (`ModelTypeProcessor.php`) |
| 6 | `PieceProductSyncSubscriber` legacy | ❌ Inchangé (`recomputeSingleEntityChangeSet` toujours là) |
| 7 | Double flush dans les processors | ❌ Inchangé (`ComposantProcessor.php:45,132`) |
| 8 | `MachineStructureController` God controller | ❌ **Aggravé** : 300+ → **1121 lignes** |
| 9 | Dépendance circulaire `useMachineDetailData` | ❌ Proxy ref toujours en place (`:133`) |
| 10 | Typage `any` systématique | ❌ **179 occurrences** dans 26 composables |
**Le point clé :** la Phase 1 « quick wins » du plan (items 1, 5, 6, 7 — effort S chacun, sans impact d'interface) n'a pas été entamée en presque 3 mois, alors que ce sont les corrections au meilleur ratio risque/bénéfice du projet. Le `MachineStructureController` qui grossit de +150 lignes confirme la trajectoire : sans extraction de services, chaque feature l'alourdit.
---
## 5. Documentation et configuration
### 5.1 IMPORTANT — Toute la doc décrit encore un submodule qui n'existe plus
**Fichiers :** `README.md:49-50,291-296`, `DEPLOY.md:58,62,208`, `RELEASE.md:49,117`, `frontend/README.md:150-154`
Le frontend a été intégré au monorepo (cf. CLAUDE.md « plus de submodule »), mais le README explique `git clone --recurse-submodules`, un workflow de commit en deux temps, et DEPLOY/RELEASE font des `git submodule update`. Un nouveau dev qui suit le README perd du temps sur des commandes sans effet ; un déploiement scripté depuis DEPLOY.md exécute des étapes mortes.
**Correction :** purger toute mention de submodule des 4 fichiers, décrire le workflow monorepo (1 commit racine).
### 5.2 IMPORTANT — `DEPLOY.md` utilise l'utilisateur PG d'un autre projet
**Fichier :** `DEPLOY.md` (6 occurrences de `ferme_user`)
Les commandes psql/backup de DEPLOY.md utilisent `ferme_user`/`fermerecette` — copié-collé du projet **Ferme**. Le vrai user prod est `inventory_user` (cf. `infra/prod/.env.example`). Quelqu'un qui suit la doc en incident de prod tape des commandes qui échouent (ou pire, sur la mauvaise base si les deux co-habitent sur l'instance partagée).
**Correction :** remplacer les 6 occurrences par `inventory_user` et des placeholders de mot de passe.
### 5.3 MOYEN — `RELEASE.md` et `CLAUDE.md` référencent un fichier `VERSION` inexistant
**Fichiers :** `RELEASE.md:17,50,80,82`, `CLAUDE.md` (arbre projet)
La version vit dans `config/version.yaml` (1.9.47) — le fichier `VERSION` n'existe plus. `scripts/release.sh` et la CI (`auto-tag-develop.yml`) sont à jour, mais la doc décrit l'ancien système, y compris « `frontend/nuxt.config.ts` lit `VERSION` au build ».
**Correction :** mettre à jour RELEASE.md et la ligne d'arbre dans CLAUDE.md vers `config/version.yaml`.
### 5.4 MINEUR — Pas de `.env.example` backend ni de `.env.docker.local.example`
Un nouveau dev n'a aucun modèle des variables attendues côté backend (le README pointe vers `infra/dev/.env.docker.local`… qui est censé être local/ignoré, cf. 1.12).
**Correction :** créer `infra/dev/.env.docker.local.example` avec placeholders.
### 5.5 MINEUR — Fichiers de données métier à la racine du projet
`Company (1).json`, `customer.json`, `customer.original.json`, `Ensemble simple rotor.pdf`, `inventory_prod (2).sql.gz` traînent à la racine (untracked — le `.sql.gz` est protégé par le gitignore, **pas les `.json` ni le `.pdf`** : un `git add .` distrait les commiterait, et `customer.json` ressemble à des données client réelles → RGPD).
**Correction :** déplacer hors du dépôt (ex. `~/imports/`), et ajouter une règle défensive au `.gitignore` (`/*.json` racine, `/*.pdf`).
---
## 6. Frontend et UX
### 6.1 MOYEN — Erreurs avalées : `console.error` sans feedback utilisateur
**Fichiers :** 57+ occurrences ; ex. `useMachineDetailData.ts:372,385`, `useProfileSession.ts:27`
Le pattern dominant en cas d'échec API est `console.error(...)` puis on continue avec `null`/`[]`. `useApi` toaste déjà les erreurs HTTP, donc une partie est couverte — mais les erreurs réseau/parsing et les branches qui catchent **avant** le toast laissent l'utilisateur devant une page partiellement vide sans explication, et créent du double-reporting ailleurs.
**Correction :** définir une règle unique : `useApi` est le seul à toaster ; les composables ne re-loggent pas, mais positionnent un état d'erreur (`error.value = ...`) que la page affiche (bandeau « Impossible de charger X — Réessayer »).
### 6.2 MOYEN — Appel `/maintenance/check` à chaque navigation
**Fichier :** `frontend/app/middleware/profile.global.ts:34-39`
Chaque changement de route d'un non-admin déclenche un aller-retour API. Latence ajoutée à **toutes** les navigations pour un état qui change une fois par an.
**Correction :** cacher le résultat dans un `useState` avec TTL (ex. 60 s) ; le 503 éventuel d'un appel API normal peut aussi servir de signal.
### 6.3 Rappels (déjà au §4)
`any` ×179, composables géants, dépendance circulaire — voir tableau §4 et `docs/REVIEW_ARCHITECTURE.md` pour les solutions détaillées.
---
## 7. CI/CD et dépendances
### 7.1 IMPORTANT — Aucun garde-fou automatisé : la CI ne lance ni tests ni lint
**Fichiers :** `.gitea/workflows/auto-tag-develop.yml`, `.gitea/workflows/build-docker.yml`, workflow de commit (CLAUDE.md : « committer avec `--no-verify` »)
La chaîne actuelle : push sur `develop` → auto-tag → build Docker → image prod. **Aucune étape ne lance PHPUnit, php-cs-fixer, ESLint ou `nuxi typecheck`.** Et comme le hook pre-commit (qui devait jouer ce rôle) est trop lent, la convention projet est de le contourner avec `--no-verify`. Résultat : il est possible de tagger et construire une image de prod avec des tests rouges sans qu'aucun système ne le signale.
**Pourquoi c'est le finding process le plus important de cette review :** la suite de tests est bonne (48 fichiers, DAMA, factories) — mais une suite de tests qui ne tourne pas en CI ne protège rien.
**Correction :** ajouter un workflow `ci.yml` déclenché sur PR + push develop :
1. `composer install` + `php-cs-fixer --dry-run` + PHPUnit (avec un service PG 16).
2. `npm ci` + `eslint` + `npx nuxi typecheck` + `npm run build` dans `frontend/`.
3. Faire dépendre `auto-tag-develop` du succès de la CI (ou au minimum bloquer les PR).
Le hook pre-commit lent peut alors être assumé comme optionnel.
### 7.2 MOYEN — Le build Docker de prod n'est pas testé avant le push
`build-docker.yml` build + push `latest` dès qu'un tag est posé — sans health-check de l'image (la CI de 7.1 règle l'essentiel ; un `docker run --rm image php bin/console about` est un bon smoke test bon marché).
### 7.3 Dépendances
- Backend : propre, à jour (Symfony 8.0.*, AP ^4.2, ORM ^3.6). `symfony/twig-bundle` à vérifier : aucun template Twig dans `templates/` — si seul le MCP bundle le requiert, le laisser en dépendance transitive.
- Frontend : `@nuxtjs/tailwindcss` à supprimer (cf. 3.3). Le reste est à jour et utilisé.
---
## 8. Hygiène git
Synthèse des fichiers trackés à tort (détails en §1/§5) :
| Fichier | Problème | Action |
|---------|----------|--------|
| `.mcp.json` | Credentials prod | `git rm --cached` + gitignore + **rotation** |
| `create_test_user.php` | Script debug + credentials | `git rm` |
| `scripts/{check,fix,restore,migrate,verify}-prod-*.php` (9) | Mot de passe PG prod | rotation + archive/suppression |
| `infra/dev/.env.docker.local` | Censé être local, déjà tracké | `git rm --cached` + `.example` |
| `.claude/settings.json` | Inoffensif (config plugins) | OK, peut rester |
Gaps `.gitignore` racine : `/node_modules/`, `.mcp.json`, `/*.json` (défensif), `/*.pdf`.
> Note : contrairement au premier rapport d'agent, `.claude/settings.local.json` n'est **pas** tracké — pas d'action nécessaire.
---
## 9. Bonnes pratiques à retenir
### Ce qui est bien fait dans le projet
- **`declare(strict_types=1)` partout**, attributs PHP 8 modernes, code backend homogène et lisible.
- **Sécurité API systématique** : chaque opération API Platform porte son `security:`, chaque controller custom ouvre par `denyAccessUnlessGranted()` — aucun endpoint accidentellement public trouvé.
- **Rate limiting** sur le login **et** sur l'auth MCP (souvent oublié ailleurs).
- **IDs CUID** : pas d'énumération séquentielle possible.
- **Upload : validation MIME par contenu** (`finfo`), nom de fichier régénéré (CUID), stockage hors de `public/` — et SVG exclu de l'allowlist (XSS évité).
- **`serve()` documents** : `nosniff` + `CSP: sandbox` — au-dessus du standard.
- **Système d'audit** propre (`AbstractAuditSubscriber` en template method, flag `skipAudit` réfléchi).
- **Tests solides** : 48 fichiers, `AbstractApiTestCase` avec factories, DAMA rollback, coûts de hash réduits en test.
- **Le refacto `customFields.ts` a été fait** — la dette de REVIEW_ARCHITECTURE n'est pas ignorée, juste lente.
- **Docker prod multi-stage** propre, page maintenance nginx, volumes nommés pour le storage.
- **Zéro `console.log`**, zéro TODO/FIXME oublié dans `src/` et `frontend/app/`.
### Les règles à graver
1. **Un secret commité est un secret grillé** : la suppression du fichier ne suffit jamais, il faut **révoquer** (cf. `.mcp.json`, scripts prod).
2. **`.gitignore` n'agit pas sur les fichiers déjà trackés** — `git rm --cached` d'abord.
3. **Une suite de tests qui ne tourne pas en CI ne protège rien** : si le hook est trop lent pour être vécu, le garde-fou doit vivre en CI.
4. **Toute limite (taille, pagination, timeout) doit être choisie, pas héritée d'un défaut** — sinon c'est le défaut le plus bas de la chaîne qui décide (nginx 1 Mo).
5. **Un fallback doit échouer bruyamment ou être correct**`|| 'http://localhost:3000'` en prod est le pire des deux mondes.
6. **La doc copiée d'un autre projet est pire que pas de doc** (`ferme_user` dans DEPLOY.md).
7. **Config morte = mensonge** : un `csrfToken` non branché fait croire qu'une protection existe.
8. **Les scripts one-shot ont une date de péremption** : après usage → `_archives/` ou suppression.
9. **Quand un God controller existe, chaque feature le fait grossir** : extraire tôt (1121 lignes et ça monte).
10. **Les quick wins planifiés perdent leur valeur s'ils ne sont jamais faits** : la Phase 1 de REVIEW_ARCHITECTURE (4×effort S) attend depuis 3 mois.
---
## Résumé par priorité
| Priorité | # | Problème | Fichier |
|----------|---|----------|---------|
| **P0** | 1.1 | Credentials prod + token Lesstime commités (rotation requise) | `.mcp.json` |
| **P0** | 1.2 | Script admin avec mot de passe en clair tracké | `create_test_user.php` |
| **P0** | 1.3 | Mot de passe PG prod dans 9 scripts trackés | `scripts/*-prod-*.php` |
| **P1** | 7.1 | CI sans tests ni lint + hook contourné | `.gitea/workflows/` |
| **P1** | 1.4/2.2 | Aucune limite d'upload choisie (et défaut nginx 1 Mo) | `DocumentUploadProcessor.php`, `CommentController.php`, `infra/` |
| **P1** | 2.1 | Fallback API `localhost:3000` en prod | `frontend/app/composables/useApi.ts:18` |
| **P1** | 5.1 | Doc submodule obsolète (4 fichiers) | `README.md`, `DEPLOY.md`, `RELEASE.md`, `frontend/README.md` |
| **P1** | 5.2 | `ferme_user` dans DEPLOY.md | `DEPLOY.md` |
| **P1** | 1.5 | Garde path-traversal incomplet | `DocumentStorageService.php:28-42` |
| **P2** | 1.6 | CSRF : une seule barrière (SameSite) | `framework.yaml`, `useApi.ts` |
| **P2** | 1.7 | Session fixation : protection désactivée globalement | `security.yaml:5` |
| **P2** | 1.8 | Mot de passe MCP dans headers + HTTP sans TLS | `McpHeaderAuthenticator.php` |
| **P2** | 2.3 | Double mécanisme maintenance non documenté | `MaintenanceController.php`, `nginx.conf` |
| **P2** | 2.4 | Provider `email` vs email nullable | `security.yaml:18`, `Profile.php` |
| **P2** | 3.3 | `@nuxtjs/tailwindcss` inutilisé (TW3 vs TW4) | `frontend/package.json:21` |
| **P2** | 5.3 | Doc `VERSION` obsolète | `RELEASE.md`, `CLAUDE.md` |
| **P2** | 5.5 | Données client à la racine (`customer.json`…) | racine projet |
| **P2** | 6.1 | Erreurs avalées sans feedback UI | composables (57+) |
| **P2** | 6.2 | Maintenance check à chaque navigation | `profile.global.ts:34-39` |
| **P2** | 1.12 | `.env.docker.local` tracké malgré gitignore | `infra/dev/.env.docker.local` |
| **P3** | 1.9 | Commentaires+upload en ROLE_VIEWER (à confirmer métier) | `CommentController.php:33` |
| **P3** | 1.10 | Pagination max 2000/1000/500 | `Constructeur.php`, `Document.php` |
| **P3** | 1.11/3.2 | Secrets dev faibles + vars JWT mortes | `infra/dev/.env.docker.local` |
| **P3** | 3.1 | Config morte nuxt.config (5 clés) | `frontend/nuxt.config.ts:56-59` |
| **P3** | 3.4 | Dockerfile dev : blocs commentés Oracle/MySQL | `infra/dev/Dockerfile` |
| **P3** | 3.5 | `node_modules/` orphelin racine + gitignore | racine projet |
| **P3** | 3.6 | 3 fichiers `.js` non typés | `frontend/app/utils/` |
| **P3** | 1.10 | `download()` sans nosniff/CSP | `DocumentServeController.php:105-116` |
| **P3** | 5.4 | Pas de `.env.docker.local.example` | `infra/dev/` |
| **P3** | 7.2 | Image Docker poussée sans smoke test | `build-docker.yml` |
> La dette d'architecture (§4) a son propre plan dans `docs/REVIEW_ARCHITECTURE.md` — recommandation : exécuter enfin sa **Phase 1** (4 corrections effort S, sans impact d'interface).
+343
View File
@@ -0,0 +1,343 @@
# Tickets correctifs — Projet Inventory
> Liste de tâches issues de la review du 2026-06-11 (`REVIEW.md`).
> Chaque ticket est autonome : contexte, ce qu'il faut faire, fichiers concernés.
> Commence par les P0, puis P1, etc. Convention de commit : `fix(T-XXX) : description courte`.
---
## P0 — Urgents (sécurité)
### T-001 — Révoquer et retirer les credentials de `.mcp.json`
**Pourquoi :** le fichier `.mcp.json` est dans git et contient le mot de passe admin MCP de production (`A123`) et un token Lesstime valide. Toute personne avec accès au dépôt (ou à son historique) peut se connecter aux deux systèmes. Supprimer le fichier ne suffit pas : git garde l'historique — il faut **changer les secrets**.
**À faire :**
1. Changer le mot de passe du profil `admin-default-profile` sur `inventory.malio-dev.fr` (et choisir un vrai mot de passe, pas `A123`).
2. Régénérer le bearer token Lesstime côté Lesstime.
3. Sortir le fichier de git sans le supprimer du disque :
```bash
git rm --cached .mcp.json
echo ".mcp.json" >> .gitignore
```
4. Créer `.mcp.json.example` avec des placeholders :
```json
{
"mcpServers": {
"inventory": {
"type": "http",
"url": "https://inventory.malio-dev.fr/_mcp",
"headers": {
"X-Profile-Id": "<PROFILE_ID>",
"X-Profile-Password": "<PASSWORD>"
}
}
}
}
```
5. Remettre les nouveaux secrets dans ton `.mcp.json` local (désormais ignoré).
**Fichiers :** `.mcp.json`, `.gitignore`, `.mcp.json.example` (nouveau)
### T-002 — Supprimer `create_test_user.php` et vérifier la prod
**Pourquoi :** ce script de debug, commité à la racine, crée un compte `ROLE_ADMIN` avec `admin@admin.com` / `admin123` — un mot de passe devinable en quelques essais. S'il a déjà tourné en production, un compte admin faible existe peut-être en ce moment.
**À faire :**
1. Vérifier en prod qu'aucun profil `admin@admin.com` n'est actif :
```sql
SELECT id, email, is_active, roles FROM profiles WHERE email = 'admin@admin.com';
```
S'il existe : le désactiver ou changer son mot de passe immédiatement.
2. Supprimer le script :
```bash
git rm create_test_user.php
```
3. (Optionnel) Si le besoin « créer un admin de dev » existe encore, créer une commande Symfony `app:create-admin` qui prend le mot de passe en argument — ne jamais le hardcoder.
**Fichiers :** `create_test_user.php`
### T-003 — Changer le mot de passe PG prod et archiver les scripts `*-prod-*.php`
**Pourquoi :** 9 scripts dans `scripts/` contiennent le mot de passe de la base de production en clair (`fermerecette`). Ce sont des scripts de réparation one-shot qui ont déjà servi : ils n'ont plus de raison d'être dans le dépôt avec des secrets dedans.
**À faire :**
1. Changer le mot de passe de l'utilisateur PG concerné sur le serveur de prod.
2. Archiver les scripts (le dossier `_archives/` est déjà dans le `.gitignore`) :
```bash
mkdir -p _archives/scripts-prod
git rm scripts/check-prod-values.php scripts/check-prod-audit-dates.php \
scripts/check-prod-missing-piece-cfs.php scripts/check-prod-orphaned-detail.php \
scripts/fix-prod-all.php scripts/fix-prod-recreate-and-migrate.php \
scripts/migrate-orphaned-custom-fields.php scripts/restore-custom-field-values.php \
scripts/verify-prod-health.php
# (les copies locales peuvent aller dans _archives/scripts-prod si tu veux les garder)
```
3. Si l'un d'eux doit rester utilisable : remplacer les credentials en dur par `getenv('DATABASE_URL')`.
**Fichiers :** `scripts/*-prod-*.php`, `scripts/migrate-orphaned-custom-fields.php`, `scripts/restore-custom-field-values.php`
---
## P1 — Importants
### T-004 — Ajouter une CI qui lance tests et lint
**Pourquoi :** aujourd'hui, rien ne lance les tests automatiquement : la CI Gitea ne fait que tagger et builder l'image Docker, et le hook pre-commit est contourné avec `--no-verify` (trop lent). On peut donc livrer en prod avec des tests rouges sans aucune alerte.
**À faire :**
1. Créer `.gitea/workflows/ci.yml` :
```yaml
name: CI
on:
pull_request:
push:
branches: [develop]
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: root
POSTGRES_PASSWORD: root
POSTGRES_DB: inventory_test
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: '8.4', extensions: 'pdo_pgsql, intl' }
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/php-cs-fixer fix --dry-run --diff --allow-risky=yes
- run: php bin/console doctrine:schema:create --env=test
env: { DATABASE_URL: "postgresql://root:root@localhost:5432/inventory_test" }
- run: vendor/bin/phpunit
env: { DATABASE_URL: "postgresql://root:root@localhost:5432/inventory_test" }
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
working-directory: frontend
- run: npx eslint .
working-directory: frontend
- run: npx nuxi typecheck
working-directory: frontend
- run: npm run build
working-directory: frontend
```
> Adapter les détails (version PHP exacte, env de test) au premier run — l'important est que les 4 vérifications (cs-fixer, PHPUnit, ESLint, typecheck) tournent.
2. Dans Gitea, marquer ce workflow comme requis pour merger une PR vers `develop`.
**Fichiers :** `.gitea/workflows/ci.yml` (nouveau)
### T-005 — Définir une limite de taille d'upload à tous les niveaux
**Pourquoi :** aucune limite n'est choisie nulle part. Conséquence double : en prod, nginx applique son défaut de **1 Mo** (les gros PDF sont sans doute rejetés avec une erreur brute), et côté application rien n'empêcherait de remplir le disque si les limites infra étaient relevées. Décision à prendre : 50 Mo max (à ajuster au métier).
**À faire :**
1. Check applicatif dans `DocumentUploadProcessor::handleMultipartUpload()` (après la validation MIME, ligne ~79) :
```php
private const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 Mo
if ($file->getSize() > self::MAX_UPLOAD_BYTES) {
throw new BadRequestHttpException('Fichier trop volumineux (max 50 Mo).');
}
```
2. Même check dans `CommentController::create()` dans la boucle `foreach ($files as $file)` (ligne ~106) — renvoyer `$this->json(['message' => 'Fichier trop volumineux (max 50 Mo).'], 400)`.
3. `infra/dev/php.ini` — ajouter :
```ini
upload_max_filesize = 50M
post_max_size = 55M
```
Et vérifier que l'image prod (infra/prod/Dockerfile) reçoit la même config.
4. `infra/prod/nginx.conf` — dans le bloc `server` :
```nginx
client_max_body_size 55m;
```
(idem dans `nginx-proxy.conf` si le proxy frontal est aussi versionné ici).
5. Tester : upload d'un PDF de ~5 Mo en local, et vérifier le message d'erreur propre au-delà de 50 Mo.
**Fichiers :** `src/State/DocumentUploadProcessor.php`, `src/Controller/CommentController.php`, `infra/dev/php.ini`, `infra/prod/nginx.conf`, `infra/prod/Dockerfile`
### T-006 — Corriger le fallback d'URL API du frontend
**Pourquoi :** si la variable `NUXT_PUBLIC_API_BASE_URL` est vide en prod, tous les appels API partent vers `http://localhost:3000` — c'est-à-dire vers la machine de l'utilisateur. L'app casse silencieusement. Un fallback vide = « même origine », ce qui est le comportement correct.
**À faire :**
1. `frontend/app/composables/useApi.ts` ligne 18 :
```ts
// AVANT
const API_BASE_URL = (publicConfig.apiBaseUrl as string) || 'http://localhost:3000'
// APRÈS (chaîne vide = même origine ; useApi ajoute déjà /api)
const API_BASE_URL = (publicConfig.apiBaseUrl as string) || ''
```
2. `frontend/nuxt.config.ts` ligne ~49 : remplacer le fallback `'http://localhost/api'` par `''` (valeur SSR jamais utilisée, SSR off — autant ne pas mentir).
3. Vérifier en dev que tout fonctionne encore (la variable est définie en dev, donc rien ne doit changer), puis `npx nuxi typecheck`.
**Fichiers :** `frontend/app/composables/useApi.ts`, `frontend/nuxt.config.ts`
### T-007 — Purger la doc « submodule » (le frontend est dans le monorepo)
**Pourquoi :** le frontend a été rapatrié dans le repo principal, mais README, DEPLOY, RELEASE et le README frontend décrivent encore le clonage `--recurse-submodules` et le workflow de commit en deux temps. Un nouveau dev (ou toi en incident de prod) suit des étapes qui n'existent plus.
**À faire :**
1. `README.md` : lignes 49-50 → `git clone <url-du-repo>` ; section « workflow » lignes ~291-296 → décrire « un seul commit depuis la racine couvre backend + frontend ».
2. `DEPLOY.md` : supprimer les `git submodule update --init --recursive` (lignes 58, 62, 208).
3. `RELEASE.md` : supprimer les étapes submodule (lignes 49, 117).
4. `frontend/README.md` : remplacer la section « submodule » (lignes 150-154) par « ce dossier fait partie du monorepo Inventory ».
**Fichiers :** `README.md`, `DEPLOY.md`, `RELEASE.md`, `frontend/README.md`
### T-008 — Corriger l'utilisateur PG dans `DEPLOY.md`
**Pourquoi :** les commandes de DEPLOY.md utilisent `ferme_user` / `fermerecette` — copié-collé du projet Ferme. Le vrai utilisateur est `inventory_user`. En situation d'incident, suivre la doc ferait taper des commandes qui échouent ou visent la mauvaise base.
**À faire :**
1. Remplacer les 6 occurrences de `ferme_user` par `inventory_user`.
2. Remplacer le mot de passe en clair par un placeholder `<mot-de-passe-prod>` (le mot de passe n'a rien à faire dans la doc).
**Fichiers :** `DEPLOY.md`
### T-009 — Durcir le garde anti path-traversal de `DocumentStorageService`
**Pourquoi :** le contrôle `realpath()` (qui vérifie que le chemin final est bien dans le dossier de stockage) est sauté quand le fichier n'existe pas, car `realpath()` renvoie `false` dans ce cas. Le risque actuel est faible (le chemin vient de la base, pas de l'utilisateur), mais le contrôle se veut une protection — autant qu'il protège vraiment.
**À faire :** dans `getAbsolutePath()` (`src/Service/DocumentStorageService.php:28-42`) :
```php
// AVANT
$absolutePath = $this->storageDir.'/'.$relativePath;
$realPath = realpath($absolutePath);
if (false !== $realPath && !str_starts_with($realPath, realpath($this->storageDir))) {
throw new RuntimeException(sprintf('Path traversal detected: "%s"', $relativePath));
}
// APRÈS — valider sur le dossier parent, qui existe toujours pour un fichier servi
$absolutePath = $this->storageDir.'/'.$relativePath;
$realParent = realpath(dirname($absolutePath));
$realStorage = realpath($this->storageDir);
if (false === $realStorage || false === $realParent
|| !str_starts_with($realParent.'/', $realStorage.'/')) {
throw new RuntimeException(sprintf('Path traversal detected: "%s"', $relativePath));
}
```
Garder le check `str_contains($relativePath, '..')` existant en première ligne. Ajouter un test unitaire avec un chemin contenant `../` et un chemin absolu.
**Fichiers :** `src/Service/DocumentStorageService.php`, `tests/` (nouveau test)
---
## P2 — Consolidation
### T-010 — Hardening CSRF : header `X-Requested-With` obligatoire sur les écritures
**Pourquoi :** l'auth est par cookie, et la seule protection contre le CSRF (un site malveillant qui fait faire des requêtes à ton navigateur connecté) est l'attribut `SameSite=Lax` du cookie. Une deuxième barrière peu coûteuse : exiger un header custom, qu'un formulaire HTML cross-site ne peut pas envoyer.
**À faire :**
1. Côté front, dans `useApi.ts`, ajouter `'X-Requested-With': 'XMLHttpRequest'` aux headers de toutes les requêtes.
2. Côté back, créer un listener `kernel.request` qui renvoie 403 si la méthode n'est pas GET/HEAD/OPTIONS, que le chemin matche `^/api` (hors `/api/session/profile` pour le login) et que le header est absent.
3. Supprimer la clé morte `csrfToken` de `nuxt.config.ts` (elle laisse croire qu'une protection CSRF existe).
4. Adapter les tests API (`AbstractApiTestCase`) pour envoyer le header.
**Fichiers :** `frontend/app/composables/useApi.ts`, `src/EventListener/` (nouveau), `frontend/nuxt.config.ts`, `tests/AbstractApiTestCase.php`
### T-011 — Test fonctionnel : l'ID de session change au login
**Pourquoi :** la protection automatique contre la fixation de session est désactivée (`session_fixation_strategy: none`, choix documenté pour la SPA) et compensée par un `$session->migrate(true)` manuel au login. Si quelqu'un supprime ce `migrate` un jour, plus rien ne protège — un test doit le verrouiller.
**À faire :** écrire un test API qui : récupère le cookie de session avant login → se loggue → vérifie que l'ID de session a changé. Ajouter un commentaire dans `SessionProfileController::activateProfile()` pointant vers `security.yaml:5`.
**Fichiers :** `tests/Api/SessionProfileTest.php` (ou équivalent), `src/Controller/SessionProfileController.php`
### T-012 — MCP : remplacer le mot de passe par un token Bearer + HTTPS
**Pourquoi :** le mot de passe du profil circule en clair dans les headers de chaque requête MCP (et l'URL configurée est en `http://`). Les headers finissent dans les logs des proxys. Un token dédié révocable, transmis en `Authorization: Bearer`, est le pattern déjà utilisé par Lesstime.
**À faire :** ajouter un champ `mcpToken` (hashé) sur Profile ou une table `api_tokens`, générer via une commande console, adapter `McpHeaderAuthenticator` pour valider le Bearer (garder le rate-limiting), mettre à jour `.mcp.json.example`, et servir `/_mcp` en HTTPS uniquement.
**Fichiers :** `src/Mcp/Security/McpHeaderAuthenticator.php`, `src/Entity/Profile.php` ou nouvelle entité, migration, `.mcp.json.example`
### T-013 — Maintenance : faire respecter le flag côté backend
**Pourquoi :** le toggle admin écrit `var/maintenance`, mais seul le **frontend** (middleware) le vérifie. Quelqu'un qui appelle l'API directement (curl, MCP, script) passe à travers. Et le second mécanisme (`maintenance.on` lu par nginx, posé par `deploy.sh`) n'est documenté nulle part.
**À faire :**
1. Listener `kernel.request` backend : si `var/maintenance` existe et que l'utilisateur n'est pas admin → 503 JSON (sauf `/api/maintenance/check` et `/api/session/*`).
2. Documenter les deux niveaux (applicatif vs nginx/deploy) dans `DEPLOY.md`.
**Fichiers :** `src/EventListener/` (nouveau), `DEPLOY.md`
### T-014 — Résoudre l'incohérence provider/email nullable
**Pourquoi :** le user provider Symfony charge les profils par `email`, mais l'email est nullable (profils « kiosque »). Ça marche par chance (l'authenticator charge par ID), mais tout futur usage du provider standard cassera sur ces profils.
**À faire :** décision à prendre — option A : email obligatoire + email technique généré pour les kiosques ; option B : provider custom qui charge par id ou email. Documenter le choix dans `docs/BACKEND.md`.
**Fichiers :** `config/packages/security.yaml`, `src/Entity/Profile.php`, éventuellement `src/Security/`
### T-015 — Supprimer `@nuxtjs/tailwindcss`
**Pourquoi :** dépendance non utilisée (le projet utilise `@tailwindcss/vite`, Tailwind 4) qui installe en plus un Tailwind 3 parallèle — bloat et risque de conflit de résolution.
**À faire :**
```bash
cd frontend && npm uninstall @nuxtjs/tailwindcss && npm run build && npx nuxi typecheck
```
**Fichiers :** `frontend/package.json`, `frontend/package-lock.json`
### T-016 — Mettre à jour la doc de versioning (`VERSION` → `config/version.yaml`)
**Pourquoi :** `RELEASE.md` (lignes 17, 50, 80, 82) et l'arbre projet de `CLAUDE.md` référencent un fichier `VERSION` qui n'existe plus — la version vit dans `config/version.yaml`.
**À faire :** remplacer toutes les références ; vérifier au passage que la description du footer frontend (« lit VERSION au build ») correspond au mécanisme réel.
**Fichiers :** `RELEASE.md`, `CLAUDE.md`
### T-017 — Sortir les données client de la racine + gitignore défensif
**Pourquoi :** `customer.json`, `Company (1).json`, `Ensemble simple rotor.pdf` (données client réelles → enjeu RGPD) traînent à la racine, non protégés par le `.gitignore` : un `git add .` distrait les commiterait.
**À faire :**
1. Déplacer ces fichiers hors du dépôt (ex. `~/imports/inventory/`). Supprimer aussi `inventory_prod (2).sql.gz` et le `node_modules/` orphelin de la racine.
2. Ajouter au `.gitignore` racine :
```
/node_modules/
/*.json
/*.pdf
```
(les `.json` légitimes du projet sont dans des sous-dossiers ou explicitement trackés — `composer.json` etc. restent suivis car déjà trackés ; pour les nouveaux, `git add -f` reste possible).
**Fichiers :** `.gitignore`, racine du projet
### T-018 — Uniformiser la gestion d'erreur frontend (état d'erreur au lieu de `console.error`)
**Pourquoi :** en cas d'échec de chargement, le pattern actuel est `console.error(...)` puis la page s'affiche à moitié vide, sans message. L'utilisateur ne sait pas que quelque chose a raté ni comment réessayer.
**À faire :** règle : `useApi` est le seul à toaster ; les composables exposent un `error: Ref<string | null>` que la page affiche (bandeau avec bouton Réessayer). Commencer par les 3 pages principales : détail machine (`useMachineDetailData.ts:372,385`), détail composant, détail pièce. Étendre ensuite au reste.
**Fichiers :** `frontend/app/composables/useMachineDetailData.ts`, `useComponentEdit.ts`, `usePieceEdit.ts`, pages correspondantes
### T-019 — Cacher le résultat de `/maintenance/check` (TTL)
**Pourquoi :** chaque navigation d'un non-admin déclenche un appel API pour vérifier la maintenance — de la latence sur toutes les transitions de page pour un état qui ne change presque jamais.
**À faire :** dans `profile.global.ts`, stocker le résultat dans un `useState` avec timestamp et ne re-fetcher que si > 60 s.
**Fichiers :** `frontend/app/middleware/profile.global.ts`
### T-020 — Détracker `infra/dev/.env.docker.local` + fournir un `.example`
**Pourquoi :** le fichier est dans le `.gitignore` mais a été commité avant l'ajout de la règle — git continue donc de le suivre. Chaque dev qui le modifie crée du diff, et ses secrets (même de dev) sont versionnés.
**À faire :**
```bash
cp infra/dev/.env.docker.local infra/dev/.env.docker.local.example
# Dans le .example : remplacer les valeurs par des placeholders <CHANGE_ME>
# et supprimer les variables JWT_* (inutilisées, cf. T-023)
git rm --cached infra/dev/.env.docker.local
git add infra/dev/.env.docker.local.example
```
Mettre à jour le README (section installation) pour mentionner la copie du `.example`.
**Fichiers :** `infra/dev/.env.docker.local`, `infra/dev/.env.docker.local.example` (nouveau), `README.md`
---
## P3 — Nice to have
### T-021 — Décision métier : commentaires (et pièces jointes) en `ROLE_VIEWER` ?
**Pourquoi :** seule écriture accessible aux lecteurs. Si c'est voulu (« tout le monde commente »), le documenter dans `docs/BACKEND.md` ; sinon passer à `ROLE_GESTIONNAIRE` (`CommentController.php:33`).
**Fichiers :** `src/Controller/CommentController.php`, `docs/BACKEND.md`
### T-022 — Revoir les pagination max (2000/1000/500)
**Pourquoi :** `Constructeur` (2000), `ConstructeurCategorie` (1000), `Document` (500) — vérifier le besoin réel du front et redescendre, ou commenter pourquoi.
**Fichiers :** `src/Entity/Constructeur.php`, `src/Entity/ConstructeurCategorie.php`, `src/Entity/Document.php`
### T-023 — Nettoyer les variables JWT et les secrets `changeme`
**Pourquoi :** `JWT_SECRET_KEY`/`JWT_PUBLIC_KEY`/`JWT_PASSPHRASE` ne servent à rien (auth session). `APP_SECRET=changeme_…` mérite une vraie valeur aléatoire locale. (Fusionne naturellement avec T-020.)
**Fichiers :** `infra/dev/.env.docker.local`
### T-024 — Supprimer la config morte de `nuxt.config.ts`
**Pourquoi :** `csrfToken`, `requestTimeout`, `enableDebug`, `enableAnalytics`, `logLevel` ne sont consommés nulle part — de la config qui ment. (`csrfToken` est déjà traité par T-010.)
**Fichiers :** `frontend/nuxt.config.ts:56-59`
### T-025 — Nettoyer le Dockerfile dev (blocs Oracle/IMAP/MySQL commentés)
**Pourquoi :** restes d'un template générique sans rapport avec un projet PostgreSQL.
**Fichiers :** `infra/dev/Dockerfile:48-53,82-100`
### T-026 — Renommer les 3 utils `.js` en `.ts`
**Pourquoi :** `documentPreview.js`, `fileIcons.js`, `printTemplates/machineReport.js` sont importés depuis du TypeScript sans types. Tâche mécanique (bon candidat pour Codex).
**Fichiers :** `frontend/app/utils/documentPreview.js`, `frontend/app/utils/fileIcons.js`, `frontend/app/utils/printTemplates/machineReport.js`
### T-027 — Ajouter `nosniff` + `CSP: sandbox` à `download()`
**Pourquoi :** `serve()` envoie ces deux headers de protection, `download()` non — asymétrie gratuite.
**À faire :** copier les deux `headers->set(...)` de `serve()` dans `download()` (`DocumentServeController.php:110-116`).
**Fichiers :** `src/Controller/DocumentServeController.php`
### T-028 — Smoke test de l'image Docker avant push
**Pourquoi :** `build-docker.yml` pousse `latest` sans vérifier que l'image démarre.
**À faire :** entre build et push : `docker run --rm gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }} php bin/console about`.
**Fichiers :** `.gitea/workflows/build-docker.yml`
> **Hors tickets :** la dette d'architecture (smartMatch dupliqué, double flush, `pendingStructure`, God controller à 1121 lignes, `any` ×179…) a déjà son plan d'action chiffré dans `docs/REVIEW_ARCHITECTURE.md`. Recommandation forte : exécuter sa **Phase 1** (4 corrections effort S, sans impact d'interface) avant qu'elle ne prenne encore 3 mois — voir `REVIEW.md` §4.
---
## Résumé
| Priorité | Tickets | Estimation |
|----------|---------|------------|
| **P0** | T-001 à T-003 | ~2h (+ rotations de secrets) |
| **P1** | T-004 à T-009 | ~1,5 j |
| **P2** | T-010 à T-020 | ~2,5 j |
| **P3** | T-021 à T-028 | ~0,5 j |
| **Total** | 28 tickets | ~5 j |
> Commence par **T-001** — tant que les secrets ne sont pas révoqués, tout le reste est secondaire.
> Pour chaque ticket, fais un commit dédié avec le numéro dans le message (ex. `fix(T-001) : retirer .mcp.json du dépôt`).
+1 -1
View File
@@ -1,7 +1,7 @@
api_platform: api_platform:
title: Inventory API title: Inventory API
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits. description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
version: 1.9.6 version: 1.9.40
defaults: defaults:
stateless: false stateless: false
cache_headers: cache_headers:
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '1.9.39' app.version: '1.9.47'
+15 -6
View File
@@ -15,10 +15,10 @@
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" /> <IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
{{ isEditMode ? 'Voir détails' : 'Modifier' }} {{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button> </button>
<NuxtLink :to="backDestination" class="btn btn-ghost btn-sm md:btn-md"> <button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" /> <IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
{{ backLabel }} {{ backLabel }}
</NuxtLink> </button>
</div> </div>
</div> </div>
</template> </template>
@@ -29,6 +29,7 @@ import IconLucideEye from '~icons/lucide/eye'
import IconLucideArrowLeft from '~icons/lucide/arrow-left' import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const route = useRoute() const route = useRoute()
const router = useRouter()
const props = defineProps<{ const props = defineProps<{
title: string title: string
@@ -43,12 +44,20 @@ defineEmits<{
'toggle-edit': [] 'toggle-edit': []
}>() }>()
const backDestination = computed(() => { // Retour : on revient à l'URL précédente pour préserver l'état de la liste
// (recherche, tri, pagination persistés en query params). Fallback sur le
// backLink si pas d'historique applicatif (accès direct, refresh, lien partagé).
const goBack = () => {
if (route.query.from === 'machine' && route.query.machineId) { if (route.query.from === 'machine' && route.query.machineId) {
return `/machine/${route.query.machineId}` router.push(`/machine/${route.query.machineId}`)
return
} }
return props.backLink if (window.history.state?.back) {
}) router.back()
return
}
router.push(props.backLink)
}
const backLabel = computed(() => { const backLabel = computed(() => {
if (route.query.from === 'machine') { if (route.query.from === 'machine') {
@@ -4,7 +4,7 @@
<ul> <ul>
<!-- First crumb (always visible) --> <!-- First crumb (always visible) -->
<li> <li>
<NuxtLink :to="crumbs[0].path" class="text-base-content/60 hover:text-primary transition-colors"> <NuxtLink :to="crumbs[0].to" class="text-base-content/60 hover:text-primary transition-colors">
{{ crumbs[0].label }} {{ crumbs[0].label }}
</NuxtLink> </NuxtLink>
</li> </li>
@@ -18,7 +18,7 @@
:key="i" :key="i"
class="hidden sm:list-item" class="hidden sm:list-item"
> >
<NuxtLink :to="crumb.path" class="text-base-content/60 hover:text-primary transition-colors"> <NuxtLink :to="crumb.to" class="text-base-content/60 hover:text-primary transition-colors">
{{ crumb.label }} {{ crumb.label }}
</NuxtLink> </NuxtLink>
</li> </li>
@@ -32,15 +32,40 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { RouteLocationRaw } from 'vue-router'
import { useListQueryMemory } from '~/composables/useListQueryMemory'
interface Crumb { interface Crumb {
label: string label: string
path: string to: RouteLocationRaw
} }
const route = useRoute() const route = useRoute()
const { remember, recall } = useListQueryMemory()
// Routes-listes dont la recherche / tri / pagination doit survivre à une
// navigation par fil d'Ariane ou menu (qui ne passe pas par l'historique).
const LIST_PATHS = ['/machines', '/catalogues/composants', '/catalogues/pieces', '/catalogues/produits']
// On enregistre la query courante dès qu'on est sur une route-liste (et à chaque
// changement de recherche/tri/pagination, qui modifie fullPath).
watch(
() => route.fullPath,
() => {
if (LIST_PATHS.includes(route.path)) remember(route.path, route.query)
},
{ immediate: true },
)
// Cible d'un crumb pointant vers une liste : on réinjecte la dernière query
// mémorisée pour restaurer l'état, sinon chemin nu (liste neuve).
const listTo = (path: string): RouteLocationRaw => {
const query = recall(path)
return query && Object.keys(query).length > 0 ? { path, query } : path
}
const crumbs = computed<Crumb[]>(() => { const crumbs = computed<Crumb[]>(() => {
const result: Crumb[] = [{ label: 'Accueil', path: '/' }] const result: Crumb[] = [{ label: 'Accueil', to: '/' }]
const path = route.path const path = route.path
// Home page — no breadcrumb // Home page — no breadcrumb
@@ -48,88 +73,88 @@ const crumbs = computed<Crumb[]>(() => {
// Machine context from query param (when navigating from a machine detail page) // Machine context from query param (when navigating from a machine detail page)
if (route.query.from === 'machine' && route.query.machineId) { if (route.query.from === 'machine' && route.query.machineId) {
result.push({ label: 'Parc machines', path: '/machines' }) result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Machine', path: `/machine/${route.query.machineId}` }) result.push({ label: 'Machine', to: `/machine/${route.query.machineId}` })
} }
// Machines // Machines
if (path === '/machines') { if (path === '/machines') {
result.push({ label: 'Parc machines', path: '/machines' }) result.push({ label: 'Parc machines', to: listTo('/machines') })
} else if (path.startsWith('/machine/') && !route.query.from) { } else if (path.startsWith('/machine/') && !route.query.from) {
result.push({ label: 'Parc machines', path: '/machines' }) result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Machine', path }) result.push({ label: 'Machine', to: path })
} }
// Catalogs // Catalogs
else if (path.startsWith('/catalogues/composants')) { else if (path.startsWith('/catalogues/composants')) {
result.push({ label: 'Composants', path: '/catalogues/composants' }) result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
} else if (path.startsWith('/catalogues/pieces')) { } else if (path.startsWith('/catalogues/pieces')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' }) result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
} else if (path.startsWith('/catalogues/produits')) { } else if (path.startsWith('/catalogues/produits')) {
result.push({ label: 'Produits', path: '/catalogues/produits' }) result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
} }
// Entity detail pages (when NOT from machine context) // Entity detail pages (when NOT from machine context)
else if (path.startsWith('/component/') && !route.query.from) { else if (path.startsWith('/component/') && !route.query.from) {
result.push({ label: 'Composants', path: '/catalogues/composants' }) result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Composant', path }) result.push({ label: 'Composant', to: path })
} else if (path.startsWith('/piece/') && !route.query.from) { } else if (path.startsWith('/piece/') && !route.query.from) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' }) result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Pièce', path }) result.push({ label: 'Pièce', to: path })
} else if (path.startsWith('/product/') && !route.query.from) { } else if (path.startsWith('/product/') && !route.query.from) {
result.push({ label: 'Produits', path: '/catalogues/produits' }) result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Produit', path }) result.push({ label: 'Produit', to: path })
} }
// Entity detail pages WITH machine context — add entity as last crumb // Entity detail pages WITH machine context — add entity as last crumb
else if (path.startsWith('/component/') && route.query.from === 'machine') { else if (path.startsWith('/component/') && route.query.from === 'machine') {
result.push({ label: 'Composant', path }) result.push({ label: 'Composant', to: path })
} else if (path.startsWith('/piece/') && route.query.from === 'machine') { } else if (path.startsWith('/piece/') && route.query.from === 'machine') {
result.push({ label: 'Pièce', path }) result.push({ label: 'Pièce', to: path })
} else if (path.startsWith('/product/') && route.query.from === 'machine') { } else if (path.startsWith('/product/') && route.query.from === 'machine') {
result.push({ label: 'Produit', path }) result.push({ label: 'Produit', to: path })
} }
// Admin pages // Admin pages
else if (path.startsWith('/sites')) { else if (path.startsWith('/sites')) {
result.push({ label: 'Sites', path: '/sites' }) result.push({ label: 'Sites', to: '/sites' })
} else if (path.startsWith('/constructeurs')) { } else if (path.startsWith('/constructeurs')) {
result.push({ label: 'Fournisseurs', path: '/constructeurs' }) result.push({ label: 'Fournisseurs', to: '/constructeurs' })
} else if (path.startsWith('/activity-log')) { } else if (path.startsWith('/activity-log')) {
result.push({ label: 'Journal d\'activité', path: '/activity-log' }) result.push({ label: 'Journal d\'activité', to: '/activity-log' })
} else if (path.startsWith('/admin')) { } else if (path.startsWith('/admin')) {
result.push({ label: 'Administration', path: '/admin' }) result.push({ label: 'Administration', to: '/admin' })
} else if (path.startsWith('/documents')) { } else if (path.startsWith('/documents')) {
result.push({ label: 'Documents', path: '/documents' }) result.push({ label: 'Documents', to: '/documents' })
} else if (path.startsWith('/comments')) { } else if (path.startsWith('/comments')) {
result.push({ label: 'Commentaires', path: '/comments' }) result.push({ label: 'Commentaires', to: '/comments' })
} }
// Category pages // Category pages
else if (path.startsWith('/component-category')) { else if (path.startsWith('/component-category')) {
result.push({ label: 'Composants', path: '/catalogues/composants' }) result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Catégorie', path }) result.push({ label: 'Catégorie', to: path })
} else if (path.startsWith('/piece-category')) { } else if (path.startsWith('/piece-category')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' }) result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Catégorie', path }) result.push({ label: 'Catégorie', to: path })
} else if (path.startsWith('/product-category')) { } else if (path.startsWith('/product-category')) {
result.push({ label: 'Produits', path: '/catalogues/produits' }) result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Catégorie', path }) result.push({ label: 'Catégorie', to: path })
} }
// Create pages // Create pages
else if (path.startsWith('/pieces/create')) { else if (path.startsWith('/pieces/create')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' }) result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Nouvelle pièce', path }) result.push({ label: 'Nouvelle pièce', to: path })
} else if (path.startsWith('/component/create')) { } else if (path.startsWith('/component/create')) {
result.push({ label: 'Composants', path: '/catalogues/composants' }) result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Nouveau composant', path }) result.push({ label: 'Nouveau composant', to: path })
} else if (path.startsWith('/product/create')) { } else if (path.startsWith('/product/create')) {
result.push({ label: 'Produits', path: '/catalogues/produits' }) result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Nouveau produit', path }) result.push({ label: 'Nouveau produit', to: path })
} else if (path === '/machines/new') { } else if (path === '/machines/new') {
result.push({ label: 'Parc machines', path: '/machines' }) result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Nouvelle machine', path }) result.push({ label: 'Nouvelle machine', to: path })
} }
return result return result
@@ -36,10 +36,10 @@
> >
<IconLucidePrinter class="w-4 h-4" aria-hidden="true" /> <IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
</button> </button>
<NuxtLink to="/machines" class="btn btn-ghost btn-sm md:btn-md"> <button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" /> <IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
Parc machines Parc machines
</NuxtLink> </button>
</div> </div>
</div> </div>
</div> </div>
@@ -52,6 +52,18 @@ import IconLucidePrinter from '~icons/lucide/printer'
import IconLucideArrowLeft from '~icons/lucide/arrow-left' import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const router = useRouter()
// Retour : revient à l'URL précédente pour préserver la recherche/filtres du
// parc machines (persistés en query params). Fallback vers /machines si pas
// d'historique applicatif (accès direct, refresh, lien partagé).
const goBack = () => {
if (window.history.state?.back) {
router.back()
return
}
router.push('/machines')
}
const props = defineProps<{ const props = defineProps<{
title: string title: string
@@ -281,7 +281,10 @@ const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}
limit.value = response.limit limit.value = response.limit
} }
catch (error: unknown) { catch (error: unknown) {
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return // Requête annulée volontairement (nouvelle recherche / démontage) : pas une
// vraie erreur. On teste le signal car ofetch encapsule l'AbortError dans
// une FetchError, donc error.name n'est pas fiable.
if (controller.signal.aborted) return
showError(extractErrorMessage(error)) showError(extractErrorMessage(error))
} }
finally { finally {
@@ -56,7 +56,9 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
// CRUD operations // CRUD operations
const refreshDocuments = async () => { const refreshDocuments = async () => {
const e = entity() const e = entity()
if (!e?.id || e._structurePiece) return // Pending / category-only nodes carry the link id (not a real entity id) and
// have no backing piece/composant — never request documents for them.
if (!e?.id || e._structurePiece || e.pendingEntity) return
loadingDocuments.value = true loadingDocuments.value = true
try { try {
const result: any = await loadDocumentsFn(e.id, { updateStore: false }) const result: any = await loadDocumentsFn(e.id, { updateStore: false })
@@ -70,7 +72,8 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
} }
const ensureDocumentsLoaded = async () => { const ensureDocumentsLoaded = async () => {
if (documentsLoaded.value || !entity()?.id) return const e = entity()
if (documentsLoaded.value || !e?.id || e.pendingEntity) return
await refreshDocuments() await refreshDocuments()
} }
@@ -0,0 +1,17 @@
import { reactive } from 'vue'
import type { LocationQuery } from 'vue-router'
// Singleton module-level : mémorise la dernière query (recherche / tri /
// pagination / filtres) vue sur chaque route-liste. Permet aux navigations qui
// ne passent PAS par l'historique du navigateur (fil d'Ariane, menu) de
// restaurer l'état de la liste, là où router.back() le ferait pour le bouton
// Retour. SPA only (SSR off) — pas de fuite d'état entre requêtes.
const memory = reactive<Record<string, LocationQuery>>({})
export function useListQueryMemory() {
const remember = (path: string, query: LocationQuery) => {
memory[path] = { ...query }
}
const recall = (path: string): LocationQuery | undefined => memory[path]
return { remember, recall }
}
+17 -2
View File
@@ -167,7 +167,7 @@ import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes' import { usePieceTypes } from '~/composables/usePieceTypes'
import { useDataTable } from '~/composables/useDataTable' import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue' import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils' import { buildDeleteMessageWithUsage, type UsageInfo } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils' import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date' import { formatFrenchDate } from '~/utils/date'
@@ -249,10 +249,25 @@ const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(piece, 'product')) buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
const { confirm } = useConfirm() const { confirm } = useConfirm()
const api = useApi()
const handleDeletePiece = async (piece: Record<string, any>) => { const handleDeletePiece = async (piece: Record<string, any>) => {
const pieceName = piece?.name || 'cette pièce' const pieceName = piece?.name || 'cette pièce'
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
let usage: UsageInfo = {}
try {
const result = await api.get(`/pieces/${piece.id}/used-in`)
if (result.success && result.data) {
usage = {
machines: result.data.machines ?? [],
composants: result.data.composants ?? [],
}
}
} catch (error) {
console.warn('Impossible de récupérer les usages de la pièce avant suppression :', error)
}
const message = buildDeleteMessageWithUsage(pieceName, 'Cette pièce', usage)
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true }) const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
if (!confirmed) return if (!confirmed) return
await deletePiece(piece.id) await deletePiece(piece.id)
@@ -17,3 +17,74 @@ export const buildDeleteMessage = (entityName: string, impacts: string[]): strin
lines.push('Cette action est irréversible.') lines.push('Cette action est irréversible.')
return lines.join('\n\n') return lines.join('\n\n')
} }
interface UsedInMachine {
id: string
name: string | null
site?: { id: string; name: string | null } | null
}
interface UsedInEntity {
id: string
name: string | null
}
export interface UsageInfo {
machines?: UsedInMachine[]
composants?: UsedInEntity[]
pieces?: UsedInEntity[]
}
const formatMachineLine = (m: UsedInMachine): string => {
const name = m.name?.trim() || '(sans nom)'
const siteName = m.site?.name?.trim()
return siteName ? `${name} (${siteName})` : name
}
/**
* Builds a delete-confirmation message that lists the machines (and other
* entities) currently using the item. The user sees exactly what will be
* detached before they confirm the deletion.
*/
export const buildDeleteMessageWithUsage = (
entityName: string,
entityLabel: string,
usage: UsageInfo,
): string => {
const machines = usage.machines ?? []
const composants = usage.composants ?? []
const pieces = usage.pieces ?? []
const lines = [`Voulez-vous vraiment supprimer « ${entityName} » ?`]
if (machines.length > 0) {
const header = machines.length === 1
? `${entityLabel} est actuellement utilisée par 1 machine :`
: `${entityLabel} est actuellement utilisée par ${machines.length} machines :`
const bullets = machines.map((m) => `${formatMachineLine(m)}`).join('\n')
lines.push(`${header}\n${bullets}\n\nLa supprimer la retirera de ${machines.length === 1 ? 'cette machine' : 'ces machines'}.`)
}
if (composants.length > 0) {
const header = composants.length === 1
? 'Elle est également référencée par 1 composant :'
: `Elle est également référencée par ${composants.length} composants :`
const bullets = composants
.map((c) => `${c.name?.trim() || '(sans nom)'}`)
.join('\n')
lines.push(`${header}\n${bullets}`)
}
if (pieces.length > 0) {
const header = pieces.length === 1
? 'Elle est également utilisée par 1 pièce :'
: `Elle est également utilisée par ${pieces.length} pièces :`
const bullets = pieces
.map((p) => `${p.name?.trim() || '(sans nom)'}`)
.join('\n')
lines.push(`${header}\n${bullets}`)
}
lines.push('Cette action est irréversible.')
return lines.join('\n\n')
}
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useEntityDocuments } from '~/composables/useEntityDocuments'
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockLoadDocumentsByPiece = vi.fn()
const mockLoadDocumentsByComponent = vi.fn()
vi.mock('~/composables/useDocuments', () => ({
useDocuments: () => ({
loadDocumentsByPiece: mockLoadDocumentsByPiece,
loadDocumentsByComponent: mockLoadDocumentsByComponent,
uploadDocuments: vi.fn(),
deleteDocument: vi.fn(),
updateDocument: vi.fn(),
}),
}))
vi.mock('~/utils/documentPreview', () => ({
canPreviewDocument: () => true,
}))
beforeEach(() => {
vi.clearAllMocks()
})
// ---------------------------------------------------------------------------
// refreshDocuments — pending / orphan entities
// ---------------------------------------------------------------------------
describe('refreshDocuments', () => {
it('does NOT load documents for a pending piece node (orphan link id is not a piece id)', async () => {
// A category-only / pending piece node: its `id` is the machinePieceLink id,
// there is no real piece behind it (pieceId is null).
const pendingNode = {
id: 'cl48179803369dd93b4a90b784', // machinePieceLink id, NOT a piece id
pieceId: null,
pendingEntity: true,
documents: [],
}
const { refreshDocuments } = useEntityDocuments({
entity: () => pendingNode,
entityType: 'piece',
})
await refreshDocuments()
expect(mockLoadDocumentsByPiece).not.toHaveBeenCalled()
})
it('loads documents for a real piece node using its piece id', async () => {
mockLoadDocumentsByPiece.mockResolvedValue({ success: true, data: [] })
const realNode = {
id: 'clrealpieceid000000000000',
pieceId: 'clrealpieceid000000000000',
pendingEntity: false,
documents: [],
}
const { refreshDocuments } = useEntityDocuments({
entity: () => realNode,
entityType: 'piece',
})
await refreshDocuments()
expect(mockLoadDocumentsByPiece).toHaveBeenCalledWith('clrealpieceid000000000000', { updateStore: false })
})
})
@@ -0,0 +1,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\CustomField;
use App\Entity\CustomFieldValue; use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use App\Repository\ComposantRepository; use App\Repository\ComposantRepository;
use App\Repository\CustomFieldRepository; use App\Repository\CustomFieldRepository;
use App\Repository\CustomFieldValueRepository; use App\Repository\CustomFieldValueRepository;
@@ -15,6 +16,7 @@ use App\Repository\MachineRepository;
use App\Repository\PieceRepository; use App\Repository\PieceRepository;
use App\Repository\ProductRepository; use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -288,28 +290,68 @@ class CustomFieldValueController extends AbstractController
case 'machinePieceLink': case 'machinePieceLink':
$value->setMachinePieceLink($entity); $value->setMachinePieceLink($entity);
$value->setPiece($entity->getPiece()); $value->setPiece($this->ensurePieceExists($entity->getPiece()));
break; break;
} }
} }
/**
* Returns the Piece if its underlying row still exists in DB, otherwise null.
* getId() on a Doctrine proxy does NOT trigger __load(), so we force the proxy
* to initialize explicitly to handle orphan links here instead of crashing on
* the first real getter.
*/
private function ensurePieceExists(?Piece $piece): ?Piece
{
if (null === $piece) {
return null;
}
try {
$this->entityManager->initializeObject($piece);
return $piece;
} catch (EntityNotFoundException) {
return null;
}
}
/**
* getId() on a Doctrine proxy returns the identifier without triggering __load(),
* so it never raises EntityNotFoundException even if the row is gone. Force the
* proxy to initialize explicitly so an orphan CFV is handled here instead of
* crashing on the first real getter.
*/
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
{
if (null === $cf) {
return null;
}
try {
$this->entityManager->initializeObject($cf);
return $cf;
} catch (EntityNotFoundException) {
return null;
}
}
private function normalizeCustomFieldValue(CustomFieldValue $value): array private function normalizeCustomFieldValue(CustomFieldValue $value): array
{ {
$customField = $value->getCustomField(); $customField = $this->ensureCustomFieldExists($value->getCustomField());
return [ return [
'id' => $value->getId(), 'id' => $value->getId(),
'value' => $value->getValue(), 'value' => $value->getValue(),
'customFieldId' => $customField->getId(), 'customFieldId' => $customField?->getId(),
'customField' => [ 'customField' => $customField ? [
'id' => $customField->getId(), 'id' => $customField->getId(),
'name' => $customField->getName(), 'name' => $customField->getName(),
'type' => $customField->getType(), 'type' => $customField->getType(),
'required' => $customField->isRequired(), 'required' => $customField->isRequired(),
'options' => $customField->getOptions(), 'options' => $customField->getOptions(),
'orderIndex' => $customField->getOrderIndex(), 'orderIndex' => $customField->getOrderIndex(),
], ] : null,
'machineId' => $value->getMachine()?->getId(), 'machineId' => $value->getMachine()?->getId(),
'composantId' => $value->getComposant()?->getId(), 'composantId' => $value->getComposant()?->getId(),
'pieceId' => $value->getPiece()?->getId(), 'pieceId' => $value->getPiece()?->getId(),
+53 -7
View File
@@ -26,6 +26,7 @@ use App\Repository\PieceRepository;
use App\Repository\ProductRepository; use App\Repository\ProductRepository;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@@ -676,7 +677,7 @@ class MachineStructureController extends AbstractController
private function normalizePieceLinks(array $links): array private function normalizePieceLinks(array $links): array
{ {
return array_map(function (MachinePieceLink $link): array { return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece(); $piece = $this->ensurePieceExists($link->getPiece());
$modelType = $link->getModelType(); $modelType = $link->getModelType();
$parentLink = $link->getParentLink(); $parentLink = $link->getParentLink();
$type = $piece?->getTypePiece(); $type = $piece?->getTypePiece();
@@ -704,7 +705,7 @@ class MachineStructureController extends AbstractController
private function resolvePieceQuantity(MachinePieceLink $link): int private function resolvePieceQuantity(MachinePieceLink $link): int
{ {
$parentLink = $link->getParentLink(); $parentLink = $link->getParentLink();
$piece = $link->getPiece(); $piece = $this->ensurePieceExists($link->getPiece());
if (!$parentLink || !$piece) { if (!$parentLink || !$piece) {
return $link->getQuantity(); return $link->getQuantity();
@@ -716,7 +717,8 @@ class MachineStructureController extends AbstractController
} }
foreach ($composant->getPieceSlots() as $slot) { foreach ($composant->getPieceSlots() as $slot) {
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) { $selected = $this->ensurePieceExists($slot->getSelectedPiece());
if ($selected?->getId() === $piece->getId()) {
return $slot->getQuantity(); return $slot->getQuantity();
} }
} }
@@ -771,15 +773,16 @@ class MachineStructureController extends AbstractController
{ {
$pieces = []; $pieces = [];
foreach ($composant->getPieceSlots() as $slot) { foreach ($composant->getPieceSlots() as $slot) {
$selectedPiece = $this->ensurePieceExists($slot->getSelectedPiece());
$pieceData = [ $pieceData = [
'slotId' => $slot->getId(), 'slotId' => $slot->getId(),
'typePieceId' => $slot->getTypePiece()?->getId(), 'typePieceId' => $slot->getTypePiece()?->getId(),
'typePiece' => $this->normalizeModelType($slot->getTypePiece()), 'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
'quantity' => $slot->getQuantity(), 'quantity' => $slot->getQuantity(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(), 'selectedPieceId' => $selectedPiece?->getId(),
]; ];
if ($slot->getSelectedPiece()) { if ($selectedPiece) {
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece()); $pieceData['resolvedPiece'] = $this->normalizePiece($selectedPiece);
} }
$pieces[] = $pieceData; $pieces[] = $pieceData;
} }
@@ -810,6 +813,46 @@ class MachineStructureController extends AbstractController
]; ];
} }
/**
* Returns the Piece if its underlying row still exists in DB, otherwise null.
* getId() on a Doctrine proxy does NOT trigger __load() (the id is the key used
* to build the proxy), so we force initialization via initializeObject() to
* surface a stale FK here instead of crashing on the first real getter.
*/
private function ensurePieceExists(?Piece $piece): ?Piece
{
if (null === $piece) {
return null;
}
try {
$this->entityManager->initializeObject($piece);
return $piece;
} catch (EntityNotFoundException) {
return null;
}
}
/**
* Returns the CustomField if its underlying row still exists, otherwise null.
* getId() on a Doctrine proxy does NOT trigger __load() — the id is the key used
* to build the proxy. We force initialization explicitly so a stale FK to a
* deleted CustomField surfaces here instead of crashing on getName() later.
*/
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
{
if (null === $cf) {
return null;
}
try {
$this->entityManager->initializeObject($cf);
return $cf;
} catch (EntityNotFoundException) {
return null;
}
}
private function normalizePiece(Piece $piece): array private function normalizePiece(Piece $piece): array
{ {
$type = $piece->getTypePiece(); $type = $piece->getTypePiece();
@@ -920,7 +963,10 @@ class MachineStructureController extends AbstractController
if (!$cfv instanceof CustomFieldValue) { if (!$cfv instanceof CustomFieldValue) {
continue; continue;
} }
$cf = $cfv->getCustomField(); $cf = $this->ensureCustomFieldExists($cfv->getCustomField());
if (null === $cf) {
continue;
}
$items[] = [ $items[] = [
'id' => $cfv->getId(), 'id' => $cfv->getId(),
'value' => $cfv->getValue(), 'value' => $cfv->getValue(),
@@ -18,6 +18,7 @@ use DateTimeInterface;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber; use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Doctrine\ORM\Event\OnFlushEventArgs; use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Doctrine\ORM\UnitOfWork; use Doctrine\ORM\UnitOfWork;
@@ -432,7 +433,12 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
return; return;
} }
$fieldName = 'customField:'.$cfv->getCustomField()->getName(); try {
$cfName = $cfv->getCustomField()->getName();
} catch (EntityNotFoundException) {
return;
}
$fieldName = 'customField:'.$cfName;
$diff = [$fieldName => ['from' => $from, 'to' => $to]]; $diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff); $pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
+7 -2
View File
@@ -7,6 +7,7 @@ namespace App\Service;
use App\Entity\Composant; use App\Entity\Composant;
use App\Entity\CustomFieldValue; use App\Entity\CustomFieldValue;
use App\Entity\Piece; use App\Entity\Piece;
use Doctrine\ORM\EntityNotFoundException;
class ReferenceAutoGenerator class ReferenceAutoGenerator
{ {
@@ -48,8 +49,12 @@ class ReferenceAutoGenerator
/** @var CustomFieldValue $cfv */ /** @var CustomFieldValue $cfv */
foreach ($entity->getCustomFieldValues() as $cfv) { foreach ($entity->getCustomFieldValues() as $cfv) {
$normalized = mb_strtoupper(trim($cfv->getValue())); try {
$map[$cfv->getCustomField()->getName()] = $normalized; $name = $cfv->getCustomField()->getName();
} catch (EntityNotFoundException) {
continue;
}
$map[$name] = mb_strtoupper(trim($cfv->getValue()));
} }
return $map; return $map;