Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfd77a1ca1 | |||
| c02f999a32 | |||
| e05ba6a97c | |||
| 012d552ddc | |||
| 594ed7b631 | |||
| 7836f87cd2 | |||
| d5361ac3ec | |||
| 477295c400 | |||
| 22dddb73bd | |||
| cb49c69662 | |||
| f18ae545d8 | |||
| 3003ced157 | |||
| 2b318ce5d6 | |||
| c10ab08803 | |||
| 85d4726415 | |||
| af13dc0237 | |||
| 7e2cabfa65 | |||
| 003e419a93 | |||
| d1b170d87f | |||
| 0fc9daa974 |
@@ -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.
|
|
||||||
|
|||||||
@@ -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
@@ -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,7 +1,7 @@
|
|||||||
api_platform:
|
api_platform:
|
||||||
title: Inventory API
|
title: Inventory API
|
||||||
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
||||||
version: 1.9.6
|
version: 1.9.40
|
||||||
defaults:
|
defaults:
|
||||||
stateless: false
|
stateless: false
|
||||||
cache_headers:
|
cache_headers:
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '1.9.38'
|
app.version: '1.9.47'
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -5,6 +5,19 @@
|
|||||||
Ajouter une nouvelle machine
|
Ajouter une nouvelle machine
|
||||||
</h3>
|
</h3>
|
||||||
<form @submit.prevent="handleSubmit">
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div v-if="errorMessage" class="alert alert-error mb-4" role="alert">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ errorMessage }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -78,6 +91,7 @@ const props = defineProps<{
|
|||||||
sites: Array<{ id: string, name: string }>
|
sites: Array<{ id: string, name: string }>
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
preselectedSiteId?: string
|
preselectedSiteId?: string
|
||||||
|
errorMessage?: string | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<!-- First crumb (always visible) -->
|
<!-- First crumb (always visible) -->
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink :to="crumbs[0].path" class="text-base-content/60 hover:text-primary transition-colors">
|
<NuxtLink :to="crumbs[0].to" class="text-base-content/60 hover:text-primary transition-colors">
|
||||||
{{ crumbs[0].label }}
|
{{ crumbs[0].label }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
:key="i"
|
:key="i"
|
||||||
class="hidden sm:list-item"
|
class="hidden sm:list-item"
|
||||||
>
|
>
|
||||||
<NuxtLink :to="crumb.path" class="text-base-content/60 hover:text-primary transition-colors">
|
<NuxtLink :to="crumb.to" class="text-base-content/60 hover:text-primary transition-colors">
|
||||||
{{ crumb.label }}
|
{{ crumb.label }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
@@ -32,15 +32,40 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
import { useListQueryMemory } from '~/composables/useListQueryMemory'
|
||||||
|
|
||||||
interface Crumb {
|
interface Crumb {
|
||||||
label: string
|
label: string
|
||||||
path: string
|
to: RouteLocationRaw
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { remember, recall } = useListQueryMemory()
|
||||||
|
|
||||||
|
// Routes-listes dont la recherche / tri / pagination doit survivre à une
|
||||||
|
// navigation par fil d'Ariane ou menu (qui ne passe pas par l'historique).
|
||||||
|
const LIST_PATHS = ['/machines', '/catalogues/composants', '/catalogues/pieces', '/catalogues/produits']
|
||||||
|
|
||||||
|
// On enregistre la query courante dès qu'on est sur une route-liste (et à chaque
|
||||||
|
// changement de recherche/tri/pagination, qui modifie fullPath).
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
if (LIST_PATHS.includes(route.path)) remember(route.path, route.query)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cible d'un crumb pointant vers une liste : on réinjecte la dernière query
|
||||||
|
// mémorisée pour restaurer l'état, sinon chemin nu (liste neuve).
|
||||||
|
const listTo = (path: string): RouteLocationRaw => {
|
||||||
|
const query = recall(path)
|
||||||
|
return query && Object.keys(query).length > 0 ? { path, query } : path
|
||||||
|
}
|
||||||
|
|
||||||
const crumbs = computed<Crumb[]>(() => {
|
const crumbs = computed<Crumb[]>(() => {
|
||||||
const result: Crumb[] = [{ label: 'Accueil', path: '/' }]
|
const result: Crumb[] = [{ label: 'Accueil', to: '/' }]
|
||||||
const path = route.path
|
const path = route.path
|
||||||
|
|
||||||
// Home page — no breadcrumb
|
// Home page — no breadcrumb
|
||||||
@@ -48,88 +73,88 @@ const crumbs = computed<Crumb[]>(() => {
|
|||||||
|
|
||||||
// Machine context from query param (when navigating from a machine detail page)
|
// Machine context from query param (when navigating from a machine detail page)
|
||||||
if (route.query.from === 'machine' && route.query.machineId) {
|
if (route.query.from === 'machine' && route.query.machineId) {
|
||||||
result.push({ label: 'Parc machines', path: '/machines' })
|
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||||
result.push({ label: 'Machine', path: `/machine/${route.query.machineId}` })
|
result.push({ label: 'Machine', to: `/machine/${route.query.machineId}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Machines
|
// Machines
|
||||||
if (path === '/machines') {
|
if (path === '/machines') {
|
||||||
result.push({ label: 'Parc machines', path: '/machines' })
|
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||||
} else if (path.startsWith('/machine/') && !route.query.from) {
|
} else if (path.startsWith('/machine/') && !route.query.from) {
|
||||||
result.push({ label: 'Parc machines', path: '/machines' })
|
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||||
result.push({ label: 'Machine', path })
|
result.push({ label: 'Machine', to: path })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catalogs
|
// Catalogs
|
||||||
else if (path.startsWith('/catalogues/composants')) {
|
else if (path.startsWith('/catalogues/composants')) {
|
||||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||||
} else if (path.startsWith('/catalogues/pieces')) {
|
} else if (path.startsWith('/catalogues/pieces')) {
|
||||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||||
} else if (path.startsWith('/catalogues/produits')) {
|
} else if (path.startsWith('/catalogues/produits')) {
|
||||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity detail pages (when NOT from machine context)
|
// Entity detail pages (when NOT from machine context)
|
||||||
else if (path.startsWith('/component/') && !route.query.from) {
|
else if (path.startsWith('/component/') && !route.query.from) {
|
||||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||||
result.push({ label: 'Composant', path })
|
result.push({ label: 'Composant', to: path })
|
||||||
} else if (path.startsWith('/piece/') && !route.query.from) {
|
} else if (path.startsWith('/piece/') && !route.query.from) {
|
||||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||||
result.push({ label: 'Pièce', path })
|
result.push({ label: 'Pièce', to: path })
|
||||||
} else if (path.startsWith('/product/') && !route.query.from) {
|
} else if (path.startsWith('/product/') && !route.query.from) {
|
||||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||||
result.push({ label: 'Produit', path })
|
result.push({ label: 'Produit', to: path })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity detail pages WITH machine context — add entity as last crumb
|
// Entity detail pages WITH machine context — add entity as last crumb
|
||||||
else if (path.startsWith('/component/') && route.query.from === 'machine') {
|
else if (path.startsWith('/component/') && route.query.from === 'machine') {
|
||||||
result.push({ label: 'Composant', path })
|
result.push({ label: 'Composant', to: path })
|
||||||
} else if (path.startsWith('/piece/') && route.query.from === 'machine') {
|
} else if (path.startsWith('/piece/') && route.query.from === 'machine') {
|
||||||
result.push({ label: 'Pièce', path })
|
result.push({ label: 'Pièce', to: path })
|
||||||
} else if (path.startsWith('/product/') && route.query.from === 'machine') {
|
} else if (path.startsWith('/product/') && route.query.from === 'machine') {
|
||||||
result.push({ label: 'Produit', path })
|
result.push({ label: 'Produit', to: path })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin pages
|
// Admin pages
|
||||||
else if (path.startsWith('/sites')) {
|
else if (path.startsWith('/sites')) {
|
||||||
result.push({ label: 'Sites', path: '/sites' })
|
result.push({ label: 'Sites', to: '/sites' })
|
||||||
} else if (path.startsWith('/constructeurs')) {
|
} else if (path.startsWith('/constructeurs')) {
|
||||||
result.push({ label: 'Fournisseurs', path: '/constructeurs' })
|
result.push({ label: 'Fournisseurs', to: '/constructeurs' })
|
||||||
} else if (path.startsWith('/activity-log')) {
|
} else if (path.startsWith('/activity-log')) {
|
||||||
result.push({ label: 'Journal d\'activité', path: '/activity-log' })
|
result.push({ label: 'Journal d\'activité', to: '/activity-log' })
|
||||||
} else if (path.startsWith('/admin')) {
|
} else if (path.startsWith('/admin')) {
|
||||||
result.push({ label: 'Administration', path: '/admin' })
|
result.push({ label: 'Administration', to: '/admin' })
|
||||||
} else if (path.startsWith('/documents')) {
|
} else if (path.startsWith('/documents')) {
|
||||||
result.push({ label: 'Documents', path: '/documents' })
|
result.push({ label: 'Documents', to: '/documents' })
|
||||||
} else if (path.startsWith('/comments')) {
|
} else if (path.startsWith('/comments')) {
|
||||||
result.push({ label: 'Commentaires', path: '/comments' })
|
result.push({ label: 'Commentaires', to: '/comments' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category pages
|
// Category pages
|
||||||
else if (path.startsWith('/component-category')) {
|
else if (path.startsWith('/component-category')) {
|
||||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||||
result.push({ label: 'Catégorie', path })
|
result.push({ label: 'Catégorie', to: path })
|
||||||
} else if (path.startsWith('/piece-category')) {
|
} else if (path.startsWith('/piece-category')) {
|
||||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||||
result.push({ label: 'Catégorie', path })
|
result.push({ label: 'Catégorie', to: path })
|
||||||
} else if (path.startsWith('/product-category')) {
|
} else if (path.startsWith('/product-category')) {
|
||||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||||
result.push({ label: 'Catégorie', path })
|
result.push({ label: 'Catégorie', to: path })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create pages
|
// Create pages
|
||||||
else if (path.startsWith('/pieces/create')) {
|
else if (path.startsWith('/pieces/create')) {
|
||||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||||
result.push({ label: 'Nouvelle pièce', path })
|
result.push({ label: 'Nouvelle pièce', to: path })
|
||||||
} else if (path.startsWith('/component/create')) {
|
} else if (path.startsWith('/component/create')) {
|
||||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||||
result.push({ label: 'Nouveau composant', path })
|
result.push({ label: 'Nouveau composant', to: path })
|
||||||
} else if (path.startsWith('/product/create')) {
|
} else if (path.startsWith('/product/create')) {
|
||||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||||
result.push({ label: 'Nouveau produit', path })
|
result.push({ label: 'Nouveau produit', to: path })
|
||||||
} else if (path === '/machines/new') {
|
} else if (path === '/machines/new') {
|
||||||
result.push({ label: 'Parc machines', path: '/machines' })
|
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||||
result.push({ label: 'Nouvelle machine', path })
|
result.push({ label: 'Nouvelle machine', to: path })
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -36,10 +36,10 @@
|
|||||||
>
|
>
|
||||||
<IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
|
<IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
<NuxtLink to="/machines" class="btn btn-ghost btn-sm md:btn-md">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||||
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
|
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
|
||||||
Parc machines
|
Parc machines
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,6 +52,18 @@ import IconLucidePrinter from '~icons/lucide/printer'
|
|||||||
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
|
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
|
||||||
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Retour : revient à l'URL précédente pour préserver la recherche/filtres du
|
||||||
|
// parc machines (persistés en query params). Fallback vers /machines si pas
|
||||||
|
// d'historique applicatif (accès direct, refresh, lien partagé).
|
||||||
|
const goBack = () => {
|
||||||
|
if (window.history.state?.back) {
|
||||||
|
router.back()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push('/machines')
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string
|
title: string
|
||||||
|
|||||||
@@ -281,7 +281,10 @@ const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}
|
|||||||
limit.value = response.limit
|
limit.value = response.limit
|
||||||
}
|
}
|
||||||
catch (error: unknown) {
|
catch (error: unknown) {
|
||||||
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
|
// Requête annulée volontairement (nouvelle recherche / démontage) : pas une
|
||||||
|
// vraie erreur. On teste le signal car ofetch encapsule l'AbortError dans
|
||||||
|
// une FetchError, donc error.name n'est pas fiable.
|
||||||
|
if (controller.signal.aborted) return
|
||||||
showError(extractErrorMessage(error))
|
showError(extractErrorMessage(error))
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
|||||||
// CRUD operations
|
// CRUD operations
|
||||||
const refreshDocuments = async () => {
|
const refreshDocuments = async () => {
|
||||||
const e = entity()
|
const e = entity()
|
||||||
if (!e?.id || e._structurePiece) return
|
// Pending / category-only nodes carry the link id (not a real entity id) and
|
||||||
|
// have no backing piece/composant — never request documents for them.
|
||||||
|
if (!e?.id || e._structurePiece || e.pendingEntity) return
|
||||||
loadingDocuments.value = true
|
loadingDocuments.value = true
|
||||||
try {
|
try {
|
||||||
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
|
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
|
||||||
@@ -70,7 +72,8 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ensureDocumentsLoaded = async () => {
|
const ensureDocumentsLoaded = async () => {
|
||||||
if (documentsLoaded.value || !entity()?.id) return
|
const e = entity()
|
||||||
|
if (documentsLoaded.value || !e?.id || e.pendingEntity) return
|
||||||
await refreshDocuments()
|
await refreshDocuments()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
import type { LocationQuery } from 'vue-router'
|
||||||
|
|
||||||
|
// Singleton module-level : mémorise la dernière query (recherche / tri /
|
||||||
|
// pagination / filtres) vue sur chaque route-liste. Permet aux navigations qui
|
||||||
|
// ne passent PAS par l'historique du navigateur (fil d'Ariane, menu) de
|
||||||
|
// restaurer l'état de la liste, là où router.back() le ferait pour le bouton
|
||||||
|
// Retour. SPA only (SSR off) — pas de fuite d'état entre requêtes.
|
||||||
|
const memory = reactive<Record<string, LocationQuery>>({})
|
||||||
|
|
||||||
|
export function useListQueryMemory() {
|
||||||
|
const remember = (path: string, query: LocationQuery) => {
|
||||||
|
memory[path] = { ...query }
|
||||||
|
}
|
||||||
|
const recall = (path: string): LocationQuery | undefined => memory[path]
|
||||||
|
return { remember, recall }
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useMachines } from '~/composables/useMachines'
|
import { useMachines } from '~/composables/useMachines'
|
||||||
import { useSites } from '~/composables/useSites'
|
import { useSites } from '~/composables/useSites'
|
||||||
import { useToast } from '~/composables/useToast'
|
|
||||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
|
|
||||||
export function useMachineCreatePage() {
|
export function useMachineCreatePage() {
|
||||||
@@ -18,7 +17,6 @@ export function useMachineCreatePage() {
|
|||||||
|
|
||||||
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
|
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
|
||||||
const { sites, loadSites } = useSites()
|
const { sites, loadSites } = useSites()
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Local state
|
// Local state
|
||||||
@@ -27,6 +25,9 @@ export function useMachineCreatePage() {
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
|
/** Persistent error shown inline in the form (e.g. duplicate name on the same site). */
|
||||||
|
const createError = ref<string | null>(null)
|
||||||
|
|
||||||
const newMachine = reactive({
|
const newMachine = reactive({
|
||||||
name: '',
|
name: '',
|
||||||
siteId: '',
|
siteId: '',
|
||||||
@@ -41,8 +42,10 @@ export function useMachineCreatePage() {
|
|||||||
const finalizeMachineCreation = async () => {
|
const finalizeMachineCreation = async () => {
|
||||||
if (submitting.value) return
|
if (submitting.value) return
|
||||||
|
|
||||||
|
createError.value = null
|
||||||
|
|
||||||
if (!newMachine.name?.trim()) {
|
if (!newMachine.name?.trim()) {
|
||||||
toast.showError('Merci de renseigner un nom pour la machine')
|
createError.value = 'Merci de renseigner un nom pour la machine.'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,10 +83,10 @@ export function useMachineCreatePage() {
|
|||||||
await navigateTo('/machines')
|
await navigateTo('/machines')
|
||||||
}
|
}
|
||||||
} else if (result.error) {
|
} else if (result.error) {
|
||||||
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
|
createError.value = humanizeError(result.error)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
|
createError.value = humanizeError(error.message)
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -116,6 +119,7 @@ export function useMachineCreatePage() {
|
|||||||
machines,
|
machines,
|
||||||
submitting,
|
submitting,
|
||||||
loading,
|
loading,
|
||||||
|
createError,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
finalizeMachineCreation,
|
finalizeMachineCreation,
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ import { usePieces } from '~/composables/usePieces'
|
|||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { useDataTable } from '~/composables/useDataTable'
|
import { useDataTable } from '~/composables/useDataTable'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
|
import { buildDeleteMessageWithUsage, type UsageInfo } from '~/shared/utils/deleteImpactUtils'
|
||||||
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
|
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
|
||||||
import { formatFrenchDate } from '~/utils/date'
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
|
|
||||||
@@ -249,10 +249,25 @@ const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
|
|||||||
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
|
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
const handleDeletePiece = async (piece: Record<string, any>) => {
|
const handleDeletePiece = async (piece: Record<string, any>) => {
|
||||||
const pieceName = piece?.name || 'cette pièce'
|
const pieceName = piece?.name || 'cette pièce'
|
||||||
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
|
|
||||||
|
let usage: UsageInfo = {}
|
||||||
|
try {
|
||||||
|
const result = await api.get(`/pieces/${piece.id}/used-in`)
|
||||||
|
if (result.success && result.data) {
|
||||||
|
usage = {
|
||||||
|
machines: result.data.machines ?? [],
|
||||||
|
composants: result.data.composants ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Impossible de récupérer les usages de la pièce avant suppression :', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = buildDeleteMessageWithUsage(pieceName, 'Cette pièce', usage)
|
||||||
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
|
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
await deletePiece(piece.id)
|
await deletePiece(piece.id)
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
|
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
|
||||||
Ajouter un site
|
Ajouter un site
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-ghost btn-sm" @click="showAddMachineModal = true">
|
<button class="btn btn-ghost btn-sm" @click="openAddMachineModal">
|
||||||
Ajouter une machine
|
Ajouter une machine
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,7 +282,8 @@
|
|||||||
:sites="sites"
|
:sites="sites"
|
||||||
:disabled="!canEdit"
|
:disabled="!canEdit"
|
||||||
:preselected-site-id="preselectedSiteId"
|
:preselected-site-id="preselectedSiteId"
|
||||||
@close="showAddMachineModal = false"
|
:error-message="addMachineError"
|
||||||
|
@close="closeAddMachineModal"
|
||||||
@create="handleCreateMachine"
|
@create="handleCreateMachine"
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
@@ -312,6 +313,7 @@ const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
|
|||||||
// Data
|
// Data
|
||||||
const showAddSiteModal = ref(false)
|
const showAddSiteModal = ref(false)
|
||||||
const showAddMachineModal = ref(false)
|
const showAddMachineModal = ref(false)
|
||||||
|
const addMachineError = ref(null)
|
||||||
const searchTerm = ref('')
|
const searchTerm = ref('')
|
||||||
const selectedSiteFilter = ref('')
|
const selectedSiteFilter = ref('')
|
||||||
const sortOrder = ref('name-asc')
|
const sortOrder = ref('name-asc')
|
||||||
@@ -449,11 +451,14 @@ const handleCreateSite = async (data) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateMachine = async (data) => {
|
const handleCreateMachine = async (data) => {
|
||||||
|
addMachineError.value = null
|
||||||
const result = await createMachine(data)
|
const result = await createMachine(data)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showAddMachineModal.value = false
|
showAddMachineModal.value = false
|
||||||
await loadMachines()
|
await loadMachines()
|
||||||
|
} else if (result.error) {
|
||||||
|
addMachineError.value = humanizeError(result.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,9 +503,19 @@ const confirmDeleteMachine = async (machine) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openAddMachineModal = () => {
|
||||||
|
addMachineError.value = null
|
||||||
|
showAddMachineModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeAddMachineModal = () => {
|
||||||
|
addMachineError.value = null
|
||||||
|
showAddMachineModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const addMachineToSite = (site) => {
|
const addMachineToSite = (site) => {
|
||||||
preselectedSiteId.value = site.id
|
preselectedSiteId.value = site.id
|
||||||
showAddMachineModal.value = true
|
openAddMachineModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
|
|||||||
@@ -20,6 +20,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
|
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
|
||||||
|
<div v-if="c.createError" class="alert alert-error" role="alert">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-5 w-5 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ c.createError }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-sm">
|
<div class="card bg-base-100 shadow-sm">
|
||||||
<div class="card-body space-y-6">
|
<div class="card-body space-y-6">
|
||||||
<!-- Basic fields -->
|
<!-- Basic fields -->
|
||||||
|
|||||||
@@ -14,6 +14,77 @@ export const buildDeleteMessage = (entityName: string, impacts: string[]): strin
|
|||||||
if (impacts.length) {
|
if (impacts.length) {
|
||||||
lines.push(`Cela supprimera également :\n• ${impacts.join('\n• ')}`)
|
lines.push(`Cela supprimera également :\n• ${impacts.join('\n• ')}`)
|
||||||
}
|
}
|
||||||
|
lines.push('Cette action est irréversible.')
|
||||||
|
return lines.join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsedInMachine {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
site?: { id: string; name: string | null } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsedInEntity {
|
||||||
|
id: string
|
||||||
|
name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageInfo {
|
||||||
|
machines?: UsedInMachine[]
|
||||||
|
composants?: UsedInEntity[]
|
||||||
|
pieces?: UsedInEntity[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMachineLine = (m: UsedInMachine): string => {
|
||||||
|
const name = m.name?.trim() || '(sans nom)'
|
||||||
|
const siteName = m.site?.name?.trim()
|
||||||
|
return siteName ? `${name} (${siteName})` : name
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a delete-confirmation message that lists the machines (and other
|
||||||
|
* entities) currently using the item. The user sees exactly what will be
|
||||||
|
* detached before they confirm the deletion.
|
||||||
|
*/
|
||||||
|
export const buildDeleteMessageWithUsage = (
|
||||||
|
entityName: string,
|
||||||
|
entityLabel: string,
|
||||||
|
usage: UsageInfo,
|
||||||
|
): string => {
|
||||||
|
const machines = usage.machines ?? []
|
||||||
|
const composants = usage.composants ?? []
|
||||||
|
const pieces = usage.pieces ?? []
|
||||||
|
|
||||||
|
const lines = [`Voulez-vous vraiment supprimer « ${entityName} » ?`]
|
||||||
|
|
||||||
|
if (machines.length > 0) {
|
||||||
|
const header = machines.length === 1
|
||||||
|
? `${entityLabel} est actuellement utilisée par 1 machine :`
|
||||||
|
: `${entityLabel} est actuellement utilisée par ${machines.length} machines :`
|
||||||
|
const bullets = machines.map((m) => `• ${formatMachineLine(m)}`).join('\n')
|
||||||
|
lines.push(`${header}\n${bullets}\n\nLa supprimer la retirera de ${machines.length === 1 ? 'cette machine' : 'ces machines'}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (composants.length > 0) {
|
||||||
|
const header = composants.length === 1
|
||||||
|
? 'Elle est également référencée par 1 composant :'
|
||||||
|
: `Elle est également référencée par ${composants.length} composants :`
|
||||||
|
const bullets = composants
|
||||||
|
.map((c) => `• ${c.name?.trim() || '(sans nom)'}`)
|
||||||
|
.join('\n')
|
||||||
|
lines.push(`${header}\n${bullets}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pieces.length > 0) {
|
||||||
|
const header = pieces.length === 1
|
||||||
|
? 'Elle est également utilisée par 1 pièce :'
|
||||||
|
: `Elle est également utilisée par ${pieces.length} pièces :`
|
||||||
|
const bullets = pieces
|
||||||
|
.map((p) => `• ${p.name?.trim() || '(sans nom)'}`)
|
||||||
|
.join('\n')
|
||||||
|
lines.push(`${header}\n${bullets}`)
|
||||||
|
}
|
||||||
|
|
||||||
lines.push('Cette action est irréversible.')
|
lines.push('Cette action est irréversible.')
|
||||||
return lines.join('\n\n')
|
return lines.join('\n\n')
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mocks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const mockLoadDocumentsByPiece = vi.fn()
|
||||||
|
const mockLoadDocumentsByComponent = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('~/composables/useDocuments', () => ({
|
||||||
|
useDocuments: () => ({
|
||||||
|
loadDocumentsByPiece: mockLoadDocumentsByPiece,
|
||||||
|
loadDocumentsByComponent: mockLoadDocumentsByComponent,
|
||||||
|
uploadDocuments: vi.fn(),
|
||||||
|
deleteDocument: vi.fn(),
|
||||||
|
updateDocument: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('~/utils/documentPreview', () => ({
|
||||||
|
canPreviewDocument: () => true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// refreshDocuments — pending / orphan entities
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe('refreshDocuments', () => {
|
||||||
|
it('does NOT load documents for a pending piece node (orphan link id is not a piece id)', async () => {
|
||||||
|
// A category-only / pending piece node: its `id` is the machinePieceLink id,
|
||||||
|
// there is no real piece behind it (pieceId is null).
|
||||||
|
const pendingNode = {
|
||||||
|
id: 'cl48179803369dd93b4a90b784', // machinePieceLink id, NOT a piece id
|
||||||
|
pieceId: null,
|
||||||
|
pendingEntity: true,
|
||||||
|
documents: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const { refreshDocuments } = useEntityDocuments({
|
||||||
|
entity: () => pendingNode,
|
||||||
|
entityType: 'piece',
|
||||||
|
})
|
||||||
|
|
||||||
|
await refreshDocuments()
|
||||||
|
|
||||||
|
expect(mockLoadDocumentsByPiece).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads documents for a real piece node using its piece id', async () => {
|
||||||
|
mockLoadDocumentsByPiece.mockResolvedValue({ success: true, data: [] })
|
||||||
|
|
||||||
|
const realNode = {
|
||||||
|
id: 'clrealpieceid000000000000',
|
||||||
|
pieceId: 'clrealpieceid000000000000',
|
||||||
|
pendingEntity: false,
|
||||||
|
documents: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const { refreshDocuments } = useEntityDocuments({
|
||||||
|
entity: () => realNode,
|
||||||
|
entityType: 'piece',
|
||||||
|
})
|
||||||
|
|
||||||
|
await refreshDocuments()
|
||||||
|
|
||||||
|
expect(mockLoadDocumentsByPiece).toHaveBeenCalledWith('clrealpieceid000000000000', { updateStore: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260527140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Machine name uniqueness is now scoped per site: drop global unique index on machines(name), add composite unique index on (name, siteid)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Drop the global unique index/constraint on machines(name).
|
||||||
|
// Doctrine-generated name (CRC32 of table+column): uniq_f1ce8ded5e237e06.
|
||||||
|
// It may exist either as a constraint or as a bare index depending on origin,
|
||||||
|
// so we drop defensively in both forms.
|
||||||
|
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS uniq_f1ce8ded5e237e06');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS uniq_f1ce8ded5e237e06');
|
||||||
|
// Defensive fallbacks for other possible legacy names of the global unique index on name.
|
||||||
|
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS machines_name_key');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS machines_name_key');
|
||||||
|
|
||||||
|
// New uniqueness scope: a machine name is unique within a given site only.
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_machine_name_site ON machines (name, siteid)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS uniq_machine_name_site');
|
||||||
|
|
||||||
|
// Best-effort restore of the global unique index on machines(name).
|
||||||
|
// WARNING: this will fail if duplicate names now exist across sites (which the
|
||||||
|
// per-site scope allowed). Resolve duplicates manually before rolling back.
|
||||||
|
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_f1ce8ded5e237e06 ON machines (name)');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Align all FKs pointing to `pieces.id` with what entities declare
|
||||||
|
* (ON DELETE CASCADE / SET NULL). Cleans up pre-existing orphan rows
|
||||||
|
* inserted before the constraints existed, so the new FKs can be added.
|
||||||
|
*
|
||||||
|
* Mirror of Version20260506140000_FixComposantCascadeFKs for the Piece side.
|
||||||
|
*/
|
||||||
|
final class Version20260528090000_FixPieceCascadeFKs extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Align CASCADE/SET NULL FKs on pieces references (machine_piece_links, composant_piece_slots, piece_product_slots, documents, custom_field_values, piece_constructeur_links); cleanup pre-existing orphans';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// =========================================================================
|
||||||
|
// 1. Audit log : snapshot des rows orphelines avant suppression.
|
||||||
|
// =========================================================================
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'machine_piece_link',
|
||||||
|
l.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', l.id,
|
||||||
|
'machineId', l.machineid,
|
||||||
|
'pieceId', l.pieceid,
|
||||||
|
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM machine_piece_links l
|
||||||
|
WHERE l.pieceid IS NOT NULL
|
||||||
|
AND l.pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'piece_product_slot',
|
||||||
|
s.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', s.id,
|
||||||
|
'pieceId', s.pieceid,
|
||||||
|
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM piece_product_slots s
|
||||||
|
WHERE s.pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'document',
|
||||||
|
d.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', d.id,
|
||||||
|
'name', d.name,
|
||||||
|
'filename', d.filename,
|
||||||
|
'pieceId', d.pieceid,
|
||||||
|
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM documents d
|
||||||
|
WHERE d.pieceid IS NOT NULL
|
||||||
|
AND d.pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'custom_field_value',
|
||||||
|
v.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', v.id,
|
||||||
|
'pieceId', v.pieceid,
|
||||||
|
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM custom_field_values v
|
||||||
|
WHERE v.pieceid IS NOT NULL
|
||||||
|
AND v.pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'piece_constructeur_link',
|
||||||
|
l.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', l.id,
|
||||||
|
'pieceId', l.pieceid,
|
||||||
|
'constructeurId', l.constructeurid,
|
||||||
|
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM piece_constructeur_links l
|
||||||
|
WHERE l.pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. Nettoyage des orphelins (avant ADD CONSTRAINT, sinon PG rejette).
|
||||||
|
// =========================================================================
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM machine_piece_links
|
||||||
|
WHERE pieceid IS NOT NULL
|
||||||
|
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
UPDATE composant_piece_slots SET selectedpieceid = NULL
|
||||||
|
WHERE selectedpieceid IS NOT NULL
|
||||||
|
AND selectedpieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM piece_product_slots
|
||||||
|
WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM documents
|
||||||
|
WHERE pieceid IS NOT NULL
|
||||||
|
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM custom_field_values
|
||||||
|
WHERE pieceid IS NOT NULL
|
||||||
|
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM piece_constructeur_links
|
||||||
|
WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM piece_products
|
||||||
|
WHERE piece_id NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 3. Drop des éventuelles FK existantes vers `pieces` (quel que soit leur
|
||||||
|
// nom historique), puis ADD CONSTRAINT avec le bon ON DELETE.
|
||||||
|
// =========================================================================
|
||||||
|
$this->dropFksReferencingPieces('machine_piece_links', 'pieceid');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE machine_piece_links ADD CONSTRAINT fk_mpl_piece
|
||||||
|
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->dropFksReferencingPieces('composant_piece_slots', 'selectedpieceid');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE composant_piece_slots ADD CONSTRAINT fk_cps_selected_piece
|
||||||
|
FOREIGN KEY (selectedpieceid) REFERENCES pieces(id) ON DELETE SET NULL
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->dropFksReferencingPieces('piece_product_slots', 'pieceid');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE piece_product_slots ADD CONSTRAINT fk_pps_piece
|
||||||
|
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->dropFksReferencingPieces('documents', 'pieceid');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE documents ADD CONSTRAINT fk_documents_piece
|
||||||
|
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->dropFksReferencingPieces('custom_field_values', 'pieceid');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_piece
|
||||||
|
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->dropFksReferencingPieces('piece_constructeur_links', 'pieceid');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE piece_constructeur_links ADD CONSTRAINT fk_pcl_piece
|
||||||
|
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_mpl_piece');
|
||||||
|
$this->addSql('ALTER TABLE composant_piece_slots DROP CONSTRAINT IF EXISTS fk_cps_selected_piece');
|
||||||
|
$this->addSql('ALTER TABLE piece_product_slots DROP CONSTRAINT IF EXISTS fk_pps_piece');
|
||||||
|
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_piece');
|
||||||
|
$this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_piece');
|
||||||
|
$this->addSql('ALTER TABLE piece_constructeur_links DROP CONSTRAINT IF EXISTS fk_pcl_piece');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop every FK on $table.$column that references the `pieces` table,
|
||||||
|
* regardless of its historic name. Idempotent.
|
||||||
|
*/
|
||||||
|
private function dropFksReferencingPieces(string $table, string $column): void
|
||||||
|
{
|
||||||
|
$sql = <<<SQL
|
||||||
|
DO \$\$
|
||||||
|
DECLARE
|
||||||
|
fk_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR fk_name IN
|
||||||
|
SELECT tc.constraint_name
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON kcu.constraint_name = tc.constraint_name
|
||||||
|
AND kcu.table_schema = tc.table_schema
|
||||||
|
JOIN information_schema.constraint_column_usage ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name
|
||||||
|
AND ccu.table_schema = tc.table_schema
|
||||||
|
WHERE tc.table_name = '{$table}'
|
||||||
|
AND tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND kcu.column_name = '{$column}'
|
||||||
|
AND ccu.table_name = 'pieces'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE {$table} DROP CONSTRAINT %I', fk_name);
|
||||||
|
END LOOP;
|
||||||
|
END \$\$;
|
||||||
|
SQL;
|
||||||
|
$this->addSql($sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repair migration for Version20260528090000_FixPieceCascadeFKs.
|
||||||
|
*
|
||||||
|
* On some environments (prod included) that migration was recorded as executed
|
||||||
|
* but two of its six FKs to `pieces.id` never took effect:
|
||||||
|
* - machine_piece_links.pieceid (fk_mpl_piece)
|
||||||
|
* - custom_field_values.pieceid (fk_cfv_piece)
|
||||||
|
* Without them, deleting a Piece leaves orphan rows behind (a stale pieceid
|
||||||
|
* pointing to a non-existent piece), which surfaces as a "Catégorie sans item"
|
||||||
|
* ghost on the machine detail page and a 404 on /documents/piece/{id}.
|
||||||
|
*
|
||||||
|
* This migration re-applies ONLY those two missing pieces of the original one:
|
||||||
|
* snapshot orphans to audit_logs, delete them, then (re)add the FK with the
|
||||||
|
* correct ON DELETE CASCADE. Fully idempotent — safe where the FKs already exist.
|
||||||
|
*/
|
||||||
|
final class Version20260529150000_AddMissingPieceCascadeFKs extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Repair missing CASCADE FKs to pieces on machine_piece_links and custom_field_values (orphan cleanup + fk_mpl_piece / fk_cfv_piece)';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// =========================================================================
|
||||||
|
// 1. Audit log : snapshot des rows orphelines avant suppression.
|
||||||
|
// =========================================================================
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'machine_piece_link',
|
||||||
|
l.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', l.id,
|
||||||
|
'machineId', l.machineid,
|
||||||
|
'pieceId', l.pieceid,
|
||||||
|
'note', 'Cleaned by FK cascade repair migration (Version20260529150000) - referenced piece no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM machine_piece_links l
|
||||||
|
WHERE l.pieceid IS NOT NULL
|
||||||
|
AND l.pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
SELECT
|
||||||
|
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
'custom_field_value',
|
||||||
|
v.id,
|
||||||
|
'delete',
|
||||||
|
json_build_object(
|
||||||
|
'id', v.id,
|
||||||
|
'pieceId', v.pieceid,
|
||||||
|
'note', 'Cleaned by FK cascade repair migration (Version20260529150000) - referenced piece no longer existed'
|
||||||
|
),
|
||||||
|
NULL,
|
||||||
|
NOW()
|
||||||
|
FROM custom_field_values v
|
||||||
|
WHERE v.pieceid IS NOT NULL
|
||||||
|
AND v.pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. Nettoyage des orphelins (avant ADD CONSTRAINT, sinon PG rejette).
|
||||||
|
// =========================================================================
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM machine_piece_links
|
||||||
|
WHERE pieceid IS NOT NULL
|
||||||
|
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
DELETE FROM custom_field_values
|
||||||
|
WHERE pieceid IS NOT NULL
|
||||||
|
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 3. Drop des éventuelles FK existantes vers `pieces` (quel que soit leur
|
||||||
|
// nom historique), puis ADD CONSTRAINT avec le bon ON DELETE.
|
||||||
|
// =========================================================================
|
||||||
|
$this->dropFksReferencingPieces('machine_piece_links', 'pieceid');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE machine_piece_links ADD CONSTRAINT fk_mpl_piece
|
||||||
|
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$this->dropFksReferencingPieces('custom_field_values', 'pieceid');
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_piece
|
||||||
|
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_mpl_piece');
|
||||||
|
$this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_piece');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop every FK on $table.$column that references the `pieces` table,
|
||||||
|
* regardless of its historic name. Idempotent.
|
||||||
|
*/
|
||||||
|
private function dropFksReferencingPieces(string $table, string $column): void
|
||||||
|
{
|
||||||
|
$sql = <<<SQL
|
||||||
|
DO \$\$
|
||||||
|
DECLARE
|
||||||
|
fk_name TEXT;
|
||||||
|
BEGIN
|
||||||
|
FOR fk_name IN
|
||||||
|
SELECT tc.constraint_name
|
||||||
|
FROM information_schema.table_constraints tc
|
||||||
|
JOIN information_schema.key_column_usage kcu
|
||||||
|
ON kcu.constraint_name = tc.constraint_name
|
||||||
|
AND kcu.table_schema = tc.table_schema
|
||||||
|
JOIN information_schema.constraint_column_usage ccu
|
||||||
|
ON ccu.constraint_name = tc.constraint_name
|
||||||
|
AND ccu.table_schema = tc.table_schema
|
||||||
|
WHERE tc.table_name = '{$table}'
|
||||||
|
AND tc.constraint_type = 'FOREIGN KEY'
|
||||||
|
AND kcu.column_name = '{$column}'
|
||||||
|
AND ccu.table_name = 'pieces'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE {$table} DROP CONSTRAINT %I', fk_name);
|
||||||
|
END LOOP;
|
||||||
|
END \$\$;
|
||||||
|
SQL;
|
||||||
|
$this->addSql($sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- cleanup_orphan_piece_refs.sql
|
||||||
|
-- =============================================================================
|
||||||
|
-- Contexte : la suppression directe de rows dans `pieces` (bypass Doctrine /
|
||||||
|
-- FK DB sans ON DELETE CASCADE) laisse des références orphelines dans plusieurs
|
||||||
|
-- tables, ce qui fait planter l'API au chargement d'une Machine :
|
||||||
|
-- Doctrine\ORM\EntityNotFoundException: Entity of type 'App\Entity\Piece' ...
|
||||||
|
--
|
||||||
|
-- Ce script fait deux choses :
|
||||||
|
-- 1. ÉTAPE 1 (toujours exécutée) : compte les références orphelines par table
|
||||||
|
-- pour visualiser l'ampleur du problème.
|
||||||
|
-- 2. ÉTAPE 2 (commentée par défaut) : insère un audit_log par row, puis
|
||||||
|
-- DELETE / UPDATE SET NULL selon la sémantique attendue côté entité.
|
||||||
|
-- Décommenter le bloc `BEGIN; ... COMMIT;` pour appliquer.
|
||||||
|
--
|
||||||
|
-- Usage :
|
||||||
|
-- # Dry-run (compte seulement)
|
||||||
|
-- psql -h <host> -U <user> -d inventory -f scripts/cleanup_orphan_piece_refs.sql
|
||||||
|
--
|
||||||
|
-- # Application : décommenter le bloc transactionnel en bas du fichier,
|
||||||
|
-- # puis relancer la même commande. La transaction garantit l'atomicité.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================== ÉTAPE 1 : DRY-RUN ============================
|
||||||
|
\echo ''
|
||||||
|
\echo '=== Orphelins par table (Pieces) ==='
|
||||||
|
|
||||||
|
SELECT 'machine_piece_links' AS table_name, count(*) AS orphans
|
||||||
|
FROM machine_piece_links
|
||||||
|
WHERE pieceid IS NOT NULL
|
||||||
|
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'composant_piece_slots', count(*)
|
||||||
|
FROM composant_piece_slots
|
||||||
|
WHERE selectedpieceid IS NOT NULL
|
||||||
|
AND selectedpieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'piece_product_slots', count(*)
|
||||||
|
FROM piece_product_slots
|
||||||
|
WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'documents', count(*)
|
||||||
|
FROM documents
|
||||||
|
WHERE pieceid IS NOT NULL
|
||||||
|
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'custom_field_values', count(*)
|
||||||
|
FROM custom_field_values
|
||||||
|
WHERE pieceid IS NOT NULL
|
||||||
|
AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'piece_constructeur_links', count(*)
|
||||||
|
FROM piece_constructeur_links
|
||||||
|
WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'piece_products', count(*)
|
||||||
|
FROM piece_products
|
||||||
|
WHERE piece_id NOT IN (SELECT id FROM pieces)
|
||||||
|
ORDER BY table_name;
|
||||||
|
|
||||||
|
\echo ''
|
||||||
|
\echo '=> Pour appliquer le cleanup, décommenter le bloc BEGIN/COMMIT ci-dessous.'
|
||||||
|
\echo ''
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================== ÉTAPE 2 : APPLY =============================
|
||||||
|
-- Décommenter ce bloc pour exécuter le cleanup. La transaction garantit
|
||||||
|
-- l'atomicité : tout passe, ou rien (en cas d'erreur, ROLLBACK auto).
|
||||||
|
--
|
||||||
|
-- BEGIN;
|
||||||
|
--
|
||||||
|
-- -- 1. Audit log : snapshot des rows qui vont être supprimées (traçabilité prod).
|
||||||
|
--
|
||||||
|
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
-- SELECT
|
||||||
|
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
-- 'machine_piece_link',
|
||||||
|
-- l.id,
|
||||||
|
-- 'delete',
|
||||||
|
-- json_build_object(
|
||||||
|
-- 'id', l.id,
|
||||||
|
-- 'machineId', l.machineid,
|
||||||
|
-- 'pieceId', l.pieceid,
|
||||||
|
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||||
|
-- ),
|
||||||
|
-- NULL,
|
||||||
|
-- NOW()
|
||||||
|
-- FROM machine_piece_links l
|
||||||
|
-- WHERE l.pieceid IS NOT NULL
|
||||||
|
-- AND l.pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
-- SELECT
|
||||||
|
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
-- 'piece_product_slot',
|
||||||
|
-- s.id,
|
||||||
|
-- 'delete',
|
||||||
|
-- json_build_object(
|
||||||
|
-- 'id', s.id,
|
||||||
|
-- 'pieceId', s.pieceid,
|
||||||
|
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||||
|
-- ),
|
||||||
|
-- NULL,
|
||||||
|
-- NOW()
|
||||||
|
-- FROM piece_product_slots s
|
||||||
|
-- WHERE s.pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
-- SELECT
|
||||||
|
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
-- 'document',
|
||||||
|
-- d.id,
|
||||||
|
-- 'delete',
|
||||||
|
-- json_build_object(
|
||||||
|
-- 'id', d.id,
|
||||||
|
-- 'name', d.name,
|
||||||
|
-- 'filename', d.filename,
|
||||||
|
-- 'pieceId', d.pieceid,
|
||||||
|
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||||
|
-- ),
|
||||||
|
-- NULL,
|
||||||
|
-- NOW()
|
||||||
|
-- FROM documents d
|
||||||
|
-- WHERE d.pieceid IS NOT NULL
|
||||||
|
-- AND d.pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
-- SELECT
|
||||||
|
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
-- 'custom_field_value',
|
||||||
|
-- v.id,
|
||||||
|
-- 'delete',
|
||||||
|
-- json_build_object(
|
||||||
|
-- 'id', v.id,
|
||||||
|
-- 'pieceId', v.pieceid,
|
||||||
|
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||||
|
-- ),
|
||||||
|
-- NULL,
|
||||||
|
-- NOW()
|
||||||
|
-- FROM custom_field_values v
|
||||||
|
-- WHERE v.pieceid IS NOT NULL
|
||||||
|
-- AND v.pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
|
||||||
|
-- SELECT
|
||||||
|
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
|
||||||
|
-- 'piece_constructeur_link',
|
||||||
|
-- l.id,
|
||||||
|
-- 'delete',
|
||||||
|
-- json_build_object(
|
||||||
|
-- 'id', l.id,
|
||||||
|
-- 'pieceId', l.pieceid,
|
||||||
|
-- 'constructeurId', l.constructeurid,
|
||||||
|
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
|
||||||
|
-- ),
|
||||||
|
-- NULL,
|
||||||
|
-- NOW()
|
||||||
|
-- FROM piece_constructeur_links l
|
||||||
|
-- WHERE l.pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- -- 2. Nettoyage des orphelins.
|
||||||
|
--
|
||||||
|
-- DELETE FROM machine_piece_links
|
||||||
|
-- WHERE pieceid IS NOT NULL
|
||||||
|
-- AND pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- UPDATE composant_piece_slots SET selectedpieceid = NULL
|
||||||
|
-- WHERE selectedpieceid IS NOT NULL
|
||||||
|
-- AND selectedpieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- DELETE FROM piece_product_slots
|
||||||
|
-- WHERE pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- DELETE FROM documents
|
||||||
|
-- WHERE pieceid IS NOT NULL
|
||||||
|
-- AND pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- DELETE FROM custom_field_values
|
||||||
|
-- WHERE pieceid IS NOT NULL
|
||||||
|
-- AND pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- DELETE FROM piece_constructeur_links
|
||||||
|
-- WHERE pieceid NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- DELETE FROM piece_products
|
||||||
|
-- WHERE piece_id NOT IN (SELECT id FROM pieces);
|
||||||
|
--
|
||||||
|
-- -- 3. Vérification post-cleanup : tout doit être à 0.
|
||||||
|
-- SELECT 'machine_piece_links' AS table_name, count(*) AS remaining_orphans
|
||||||
|
-- FROM machine_piece_links
|
||||||
|
-- WHERE pieceid IS NOT NULL
|
||||||
|
-- AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT 'composant_piece_slots', count(*)
|
||||||
|
-- FROM composant_piece_slots
|
||||||
|
-- WHERE selectedpieceid IS NOT NULL
|
||||||
|
-- AND selectedpieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT 'piece_product_slots', count(*)
|
||||||
|
-- FROM piece_product_slots
|
||||||
|
-- WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT 'documents', count(*)
|
||||||
|
-- FROM documents
|
||||||
|
-- WHERE pieceid IS NOT NULL
|
||||||
|
-- AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT 'custom_field_values', count(*)
|
||||||
|
-- FROM custom_field_values
|
||||||
|
-- WHERE pieceid IS NOT NULL
|
||||||
|
-- AND pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT 'piece_constructeur_links', count(*)
|
||||||
|
-- FROM piece_constructeur_links
|
||||||
|
-- WHERE pieceid NOT IN (SELECT id FROM pieces)
|
||||||
|
-- UNION ALL
|
||||||
|
-- SELECT 'piece_products', count(*)
|
||||||
|
-- FROM piece_products
|
||||||
|
-- WHERE piece_id NOT IN (SELECT id FROM pieces)
|
||||||
|
-- ORDER BY table_name;
|
||||||
|
--
|
||||||
|
-- COMMIT;
|
||||||
@@ -6,6 +6,7 @@ namespace App\Controller;
|
|||||||
|
|
||||||
use App\Entity\CustomField;
|
use App\Entity\CustomField;
|
||||||
use App\Entity\CustomFieldValue;
|
use App\Entity\CustomFieldValue;
|
||||||
|
use App\Entity\Piece;
|
||||||
use App\Repository\ComposantRepository;
|
use App\Repository\ComposantRepository;
|
||||||
use App\Repository\CustomFieldRepository;
|
use App\Repository\CustomFieldRepository;
|
||||||
use App\Repository\CustomFieldValueRepository;
|
use App\Repository\CustomFieldValueRepository;
|
||||||
@@ -15,6 +16,7 @@ use App\Repository\MachineRepository;
|
|||||||
use App\Repository\PieceRepository;
|
use App\Repository\PieceRepository;
|
||||||
use App\Repository\ProductRepository;
|
use App\Repository\ProductRepository;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\EntityNotFoundException;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -288,28 +290,68 @@ class CustomFieldValueController extends AbstractController
|
|||||||
|
|
||||||
case 'machinePieceLink':
|
case 'machinePieceLink':
|
||||||
$value->setMachinePieceLink($entity);
|
$value->setMachinePieceLink($entity);
|
||||||
$value->setPiece($entity->getPiece());
|
$value->setPiece($this->ensurePieceExists($entity->getPiece()));
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Piece if its underlying row still exists in DB, otherwise null.
|
||||||
|
* getId() on a Doctrine proxy does NOT trigger __load(), so we force the proxy
|
||||||
|
* to initialize explicitly to handle orphan links here instead of crashing on
|
||||||
|
* the first real getter.
|
||||||
|
*/
|
||||||
|
private function ensurePieceExists(?Piece $piece): ?Piece
|
||||||
|
{
|
||||||
|
if (null === $piece) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->entityManager->initializeObject($piece);
|
||||||
|
|
||||||
|
return $piece;
|
||||||
|
} catch (EntityNotFoundException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* getId() on a Doctrine proxy returns the identifier without triggering __load(),
|
||||||
|
* so it never raises EntityNotFoundException even if the row is gone. Force the
|
||||||
|
* proxy to initialize explicitly so an orphan CFV is handled here instead of
|
||||||
|
* crashing on the first real getter.
|
||||||
|
*/
|
||||||
|
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
|
||||||
|
{
|
||||||
|
if (null === $cf) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->entityManager->initializeObject($cf);
|
||||||
|
|
||||||
|
return $cf;
|
||||||
|
} catch (EntityNotFoundException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeCustomFieldValue(CustomFieldValue $value): array
|
private function normalizeCustomFieldValue(CustomFieldValue $value): array
|
||||||
{
|
{
|
||||||
$customField = $value->getCustomField();
|
$customField = $this->ensureCustomFieldExists($value->getCustomField());
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $value->getId(),
|
'id' => $value->getId(),
|
||||||
'value' => $value->getValue(),
|
'value' => $value->getValue(),
|
||||||
'customFieldId' => $customField->getId(),
|
'customFieldId' => $customField?->getId(),
|
||||||
'customField' => [
|
'customField' => $customField ? [
|
||||||
'id' => $customField->getId(),
|
'id' => $customField->getId(),
|
||||||
'name' => $customField->getName(),
|
'name' => $customField->getName(),
|
||||||
'type' => $customField->getType(),
|
'type' => $customField->getType(),
|
||||||
'required' => $customField->isRequired(),
|
'required' => $customField->isRequired(),
|
||||||
'options' => $customField->getOptions(),
|
'options' => $customField->getOptions(),
|
||||||
'orderIndex' => $customField->getOrderIndex(),
|
'orderIndex' => $customField->getOrderIndex(),
|
||||||
],
|
] : null,
|
||||||
'machineId' => $value->getMachine()?->getId(),
|
'machineId' => $value->getMachine()?->getId(),
|
||||||
'composantId' => $value->getComposant()?->getId(),
|
'composantId' => $value->getComposant()?->getId(),
|
||||||
'pieceId' => $value->getPiece()?->getId(),
|
'pieceId' => $value->getPiece()?->getId(),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ use App\Repository\PieceRepository;
|
|||||||
use App\Repository\ProductRepository;
|
use App\Repository\ProductRepository;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\EntityNotFoundException;
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
@@ -676,7 +677,7 @@ class MachineStructureController extends AbstractController
|
|||||||
private function normalizePieceLinks(array $links): array
|
private function normalizePieceLinks(array $links): array
|
||||||
{
|
{
|
||||||
return array_map(function (MachinePieceLink $link): array {
|
return array_map(function (MachinePieceLink $link): array {
|
||||||
$piece = $link->getPiece();
|
$piece = $this->ensurePieceExists($link->getPiece());
|
||||||
$modelType = $link->getModelType();
|
$modelType = $link->getModelType();
|
||||||
$parentLink = $link->getParentLink();
|
$parentLink = $link->getParentLink();
|
||||||
$type = $piece?->getTypePiece();
|
$type = $piece?->getTypePiece();
|
||||||
@@ -704,7 +705,7 @@ class MachineStructureController extends AbstractController
|
|||||||
private function resolvePieceQuantity(MachinePieceLink $link): int
|
private function resolvePieceQuantity(MachinePieceLink $link): int
|
||||||
{
|
{
|
||||||
$parentLink = $link->getParentLink();
|
$parentLink = $link->getParentLink();
|
||||||
$piece = $link->getPiece();
|
$piece = $this->ensurePieceExists($link->getPiece());
|
||||||
|
|
||||||
if (!$parentLink || !$piece) {
|
if (!$parentLink || !$piece) {
|
||||||
return $link->getQuantity();
|
return $link->getQuantity();
|
||||||
@@ -716,7 +717,8 @@ class MachineStructureController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($composant->getPieceSlots() as $slot) {
|
foreach ($composant->getPieceSlots() as $slot) {
|
||||||
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
|
$selected = $this->ensurePieceExists($slot->getSelectedPiece());
|
||||||
|
if ($selected?->getId() === $piece->getId()) {
|
||||||
return $slot->getQuantity();
|
return $slot->getQuantity();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -771,15 +773,16 @@ class MachineStructureController extends AbstractController
|
|||||||
{
|
{
|
||||||
$pieces = [];
|
$pieces = [];
|
||||||
foreach ($composant->getPieceSlots() as $slot) {
|
foreach ($composant->getPieceSlots() as $slot) {
|
||||||
|
$selectedPiece = $this->ensurePieceExists($slot->getSelectedPiece());
|
||||||
$pieceData = [
|
$pieceData = [
|
||||||
'slotId' => $slot->getId(),
|
'slotId' => $slot->getId(),
|
||||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||||
'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
|
'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
|
||||||
'quantity' => $slot->getQuantity(),
|
'quantity' => $slot->getQuantity(),
|
||||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
'selectedPieceId' => $selectedPiece?->getId(),
|
||||||
];
|
];
|
||||||
if ($slot->getSelectedPiece()) {
|
if ($selectedPiece) {
|
||||||
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece());
|
$pieceData['resolvedPiece'] = $this->normalizePiece($selectedPiece);
|
||||||
}
|
}
|
||||||
$pieces[] = $pieceData;
|
$pieces[] = $pieceData;
|
||||||
}
|
}
|
||||||
@@ -810,6 +813,46 @@ class MachineStructureController extends AbstractController
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the Piece if its underlying row still exists in DB, otherwise null.
|
||||||
|
* getId() on a Doctrine proxy does NOT trigger __load() (the id is the key used
|
||||||
|
* to build the proxy), so we force initialization via initializeObject() to
|
||||||
|
* surface a stale FK here instead of crashing on the first real getter.
|
||||||
|
*/
|
||||||
|
private function ensurePieceExists(?Piece $piece): ?Piece
|
||||||
|
{
|
||||||
|
if (null === $piece) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->entityManager->initializeObject($piece);
|
||||||
|
|
||||||
|
return $piece;
|
||||||
|
} catch (EntityNotFoundException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the CustomField if its underlying row still exists, otherwise null.
|
||||||
|
* getId() on a Doctrine proxy does NOT trigger __load() — the id is the key used
|
||||||
|
* to build the proxy. We force initialization explicitly so a stale FK to a
|
||||||
|
* deleted CustomField surfaces here instead of crashing on getName() later.
|
||||||
|
*/
|
||||||
|
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
|
||||||
|
{
|
||||||
|
if (null === $cf) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->entityManager->initializeObject($cf);
|
||||||
|
|
||||||
|
return $cf;
|
||||||
|
} catch (EntityNotFoundException) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizePiece(Piece $piece): array
|
private function normalizePiece(Piece $piece): array
|
||||||
{
|
{
|
||||||
$type = $piece->getTypePiece();
|
$type = $piece->getTypePiece();
|
||||||
@@ -920,7 +963,10 @@ class MachineStructureController extends AbstractController
|
|||||||
if (!$cfv instanceof CustomFieldValue) {
|
if (!$cfv instanceof CustomFieldValue) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$cf = $cfv->getCustomField();
|
$cf = $this->ensureCustomFieldExists($cfv->getCustomField());
|
||||||
|
if (null === $cf) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'id' => $cfv->getId(),
|
'id' => $cfv->getId(),
|
||||||
'value' => $cfv->getValue(),
|
'value' => $cfv->getValue(),
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
|
|
||||||
#[ORM\Entity(repositoryClass: MachineRepository::class)]
|
#[ORM\Entity(repositoryClass: MachineRepository::class)]
|
||||||
#[ORM\Table(name: 'machines')]
|
#[ORM\Table(name: 'machines')]
|
||||||
|
#[ORM\UniqueConstraint(name: 'uniq_machine_name_site', columns: ['name', 'siteId'])]
|
||||||
#[ORM\HasLifecycleCallbacks]
|
#[ORM\HasLifecycleCallbacks]
|
||||||
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.', ignoreNull: true)]
|
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.', ignoreNull: true)]
|
||||||
|
#[UniqueEntity(fields: ['name', 'site'], message: 'Une machine avec ce nom existe déjà sur ce site.')]
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.',
|
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.',
|
||||||
operations: [
|
operations: [
|
||||||
@@ -45,7 +47,7 @@ class Machine
|
|||||||
#[Groups(['document:list'])]
|
#[Groups(['document:list'])]
|
||||||
private ?string $id = null;
|
private ?string $id = null;
|
||||||
|
|
||||||
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
|
#[ORM\Column(type: Types::STRING, length: 255)]
|
||||||
#[Groups(['document:list'])]
|
#[Groups(['document:list'])]
|
||||||
private string $name;
|
private string $name;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use DateTimeInterface;
|
|||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\Common\EventSubscriber;
|
use Doctrine\Common\EventSubscriber;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\EntityNotFoundException;
|
||||||
use Doctrine\ORM\Event\OnFlushEventArgs;
|
use Doctrine\ORM\Event\OnFlushEventArgs;
|
||||||
use Doctrine\ORM\Events;
|
use Doctrine\ORM\Events;
|
||||||
use Doctrine\ORM\UnitOfWork;
|
use Doctrine\ORM\UnitOfWork;
|
||||||
@@ -432,7 +433,12 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
|
try {
|
||||||
|
$cfName = $cfv->getCustomField()->getName();
|
||||||
|
} catch (EntityNotFoundException) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$fieldName = 'customField:'.$cfName;
|
||||||
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
|
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
|
||||||
|
|
||||||
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
|
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
|
||||||
|
|||||||
@@ -30,8 +30,9 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
|
|||||||
|
|
||||||
$constraint = $this->detectConstraintName($exception);
|
$constraint = $this->detectConstraintName($exception);
|
||||||
$error = match ($constraint) {
|
$error = match ($constraint) {
|
||||||
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
|
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
|
||||||
default => 'Un élément avec cette valeur existe déjà.',
|
'uniq_machine_name_site' => 'Une machine avec ce nom existe déjà sur ce site.',
|
||||||
|
default => 'Un élément avec cette valeur existe déjà.',
|
||||||
};
|
};
|
||||||
|
|
||||||
$event->setResponse(new JsonResponse(
|
$event->setResponse(new JsonResponse(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Service;
|
|||||||
use App\Entity\Composant;
|
use App\Entity\Composant;
|
||||||
use App\Entity\CustomFieldValue;
|
use App\Entity\CustomFieldValue;
|
||||||
use App\Entity\Piece;
|
use App\Entity\Piece;
|
||||||
|
use Doctrine\ORM\EntityNotFoundException;
|
||||||
|
|
||||||
class ReferenceAutoGenerator
|
class ReferenceAutoGenerator
|
||||||
{
|
{
|
||||||
@@ -48,8 +49,12 @@ class ReferenceAutoGenerator
|
|||||||
|
|
||||||
/** @var CustomFieldValue $cfv */
|
/** @var CustomFieldValue $cfv */
|
||||||
foreach ($entity->getCustomFieldValues() as $cfv) {
|
foreach ($entity->getCustomFieldValues() as $cfv) {
|
||||||
$normalized = mb_strtoupper(trim($cfv->getValue()));
|
try {
|
||||||
$map[$cfv->getCustomField()->getName()] = $normalized;
|
$name = $cfv->getCustomField()->getName();
|
||||||
|
} catch (EntityNotFoundException) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$map[$name] = mb_strtoupper(trim($cfv->getValue()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $map;
|
return $map;
|
||||||
|
|||||||
@@ -134,6 +134,88 @@ class MachineTest extends AbstractApiTestCase
|
|||||||
$this->assertResponseStatusCodeSame(422);
|
$this->assertResponseStatusCodeSame(422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testSameNameOnDifferentSitesIsAllowed(): void
|
||||||
|
{
|
||||||
|
$siteA = $this->createSite('Usine A');
|
||||||
|
$siteB = $this->createSite('Usine B');
|
||||||
|
$this->createMachine('Pompe', $siteA);
|
||||||
|
|
||||||
|
$client = $this->createGestionnaireClient();
|
||||||
|
$client->request('POST', '/api/machines', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => 'Pompe',
|
||||||
|
'site' => self::iri('sites', $siteB->getId()),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(201);
|
||||||
|
$this->assertJsonContains(['name' => 'Pompe']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSameNameOnSameSiteIsRejected(): void
|
||||||
|
{
|
||||||
|
$site = $this->createSite('Usine');
|
||||||
|
$this->createMachine('Pompe', $site);
|
||||||
|
|
||||||
|
$client = $this->createGestionnaireClient();
|
||||||
|
$client->request('POST', '/api/machines', [
|
||||||
|
'headers' => ['Content-Type' => 'application/ld+json'],
|
||||||
|
'json' => [
|
||||||
|
'name' => 'Pompe',
|
||||||
|
'site' => self::iri('sites', $site->getId()),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(422);
|
||||||
|
$this->assertJsonContains([
|
||||||
|
'violations' => [
|
||||||
|
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRenameToExistingNameOnSameSiteIsRejected(): void
|
||||||
|
{
|
||||||
|
$site = $this->createSite('Usine');
|
||||||
|
$this->createMachine('Pompe', $site);
|
||||||
|
$other = $this->createMachine('Moteur', $site);
|
||||||
|
|
||||||
|
$client = $this->createGestionnaireClient();
|
||||||
|
$client->request('PATCH', self::iri('machines', $other->getId()), [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => ['name' => 'Pompe'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(422);
|
||||||
|
$this->assertJsonContains([
|
||||||
|
'violations' => [
|
||||||
|
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMoveToSiteWhereNameExistsIsRejected(): void
|
||||||
|
{
|
||||||
|
$siteA = $this->createSite('Usine A');
|
||||||
|
$siteB = $this->createSite('Usine B');
|
||||||
|
$this->createMachine('Pompe', $siteB);
|
||||||
|
$machine = $this->createMachine('Pompe', $siteA);
|
||||||
|
|
||||||
|
$client = $this->createGestionnaireClient();
|
||||||
|
$client->request('PATCH', self::iri('machines', $machine->getId()), [
|
||||||
|
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||||
|
'json' => ['site' => self::iri('sites', $siteB->getId())],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(422);
|
||||||
|
$this->assertJsonContains([
|
||||||
|
'violations' => [
|
||||||
|
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function testGetStructureEndpoint(): void
|
public function testGetStructureEndpoint(): void
|
||||||
{
|
{
|
||||||
$machine = $this->createMachine('Machine structure');
|
$machine = $this->createMachine('Machine structure');
|
||||||
|
|||||||
Reference in New Issue
Block a user