Compare commits

...

38 Commits

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

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

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

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

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

- Machine : contrainte composite (name, siteId) + UniqueEntity (name, site)
- UniqueConstraintSubscriber : message explicite pour uniq_machine_name_site
- Migration : drop index global sur name + create unique index (name, siteid)
- Front : message d'erreur inline explicite à la création (page + modale)
- Tests : 4 scénarios (sites différents / même site / renommage / déplacement)
2026-05-27 15:37:38 +02:00
gitea-actions 104942a52b chore : bump version to v1.9.38
Build & Push Docker Image / build (push) Successful in 2m53s
Auto Tag Develop / tag (push) Successful in 9s
2026-05-21 14:28:44 +00:00
Matthieu c65757ee24 feat(vue-ensemble) : tri alphabétique des machines par défaut + select de tri (nom/date) + harmonisation tailles des champs de filtre
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:23:40 +02:00
Matthieu 6e105fd070 chore : bump version to v1.9.37
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-13 10:50:20 +02:00
Matthieu a0c4597de0 fix(fournisseurs) : ConstructeurSearchFilter utilise EXISTS subquery au lieu de LEFT JOIN
Le LEFT JOIN sur telephones causait une erreur PostgreSQL 'column must appear in GROUP BY' parce que Doctrine sélectionnait aussi les colonnes des téléphones joints. EXISTS subquery corrélée évite la duplication de lignes sans introduire de GROUP BY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:49:43 +02:00
Matthieu d3f269452c chore : bump version to v1.9.36
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 35s
2026-05-13 10:46:51 +02:00
gitea-actions b3fa927e77 chore : bump version to v1.9.35
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-13 08:44:31 +00:00
Matthieu f71f4c68da feat(fournisseurs) : pagination serveur + search multi-champs (name/email/telephone) + filtre catégorie + tri
Auto Tag Develop / tag (push) Has been cancelled
Backend
- Nouveau ConstructeurSearchFilter : LIKE insensible casse sur name/email + LEFT JOIN telephones.numero, accessible via ?search=
- Constructeur entity : ApiFilter ConstructeurSearchFilter, SearchFilter (categories.id exact), OrderFilter (name, email, createdAt)
- paginationMaximumItemsPerPage 200 -> 2000 (pour ConstructeurSelect et MachineDetail qui chargent l'ensemble en cache)

Frontend
- useConstructeurs : nouvelle fonction fetchConstructeursPage({ page, itemsPerPage, search, categoryId, orderField, orderDirection }) renvoyant { items, totalItems, totalPages, currentPage }
- constructeurs.vue : suppression du filtre/tri client, état page/perPage/totalItems/totalPages, watchers sur search/filter/sort qui reset page=1 et rechargent, prop pagination du DataTable câblée, recharge après create/update/delete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:44:20 +02:00
gitea-actions 905d5c0957 chore : bump version to v1.9.34
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 36s
2026-05-13 08:23:57 +00:00
Matthieu 03a5d05a2c feat(machine) : champs perso machine en badges plus gros dans entete composants et pieces
Auto Tag Develop / tag (push) Has been cancelled
Affiche les champs perso machine entre Row 1 (titre/prix) et Row 2 (fournisseur/catalogue) de l'entete ComponentItem et PieceItem.
Badges plus gros (text-sm), visibles en lecture ET en edition. Edition complete reste dans la section depliee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:23:47 +02:00
gitea-actions 069cc6e153 chore : bump version to v1.9.33
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 38s
2026-05-13 08:02:55 +00:00
Matthieu daa0cb1e28 feat(fournisseurs) : categories (M2M) + telephones (1-N) + import customer.json
Auto Tag Develop / tag (push) Successful in 9s
- Nouvelles entites ConstructeurCategorie (referentiel M2M) et ConstructeurTelephone (1-N)
- Constructeur : retrait colonne phone, ajout collections telephones/categories, groupes de serialisation constructeur:read/write
- Migration : cree les 3 tables, migre la colonne phone existante vers constructeur_telephone, drop phone
- Commande app:import-fournisseurs (dry-run par defaut, --force) : non destructive, find-or-create par nom, ne touche jamais un ID existant, ajout-seulement pour telephones/categories
- MAJ MCP tools / MachineStructureController / audit subscriber / tests
- Frontend : page constructeurs avec telephones multiples + categories (tableau, filtre, formulaire), composable useConstructeurCategories, composant ConstructeurCategorieSelect

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:02:44 +02:00
gitea-actions b147845401 chore : bump version to v1.9.32
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-11 14:52:40 +00:00
Matthieu b67af56bd1 fix(search-select) : affiche modelValue au mount en mode creatable
Auto Tag Develop / tag (push) Has been cancelled
En mode creatable, modelValue n'est pas dans options donc selectedOption est null.
Le onMounted ecrasait searchTerm a vide apres que le watch immediate l'avait initialise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:52:28 +02:00
gitea-actions 48c5c5bb33 chore : bump version to v1.9.31
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 2m47s
2026-05-11 14:25:24 +00:00
matthieu 1e2a1dae62 Merge pull request 'feat(custom-fields) : autocomplete des noms + corrections formule de référence auto' (#3) from feat/custom-field-name-autocomplete into develop
Auto Tag Develop / tag (push) Has been cancelled
Reviewed-on: #3
2026-05-11 14:25:14 +00:00
Matthieu 2a8042ba50 fix(custom-fields) : recharge la formule depuis le BE apres save du ModelType
Sur les pages d'edition de categorie composant/piece, ajoute un
loadCategory() apres updateModelType + syncExecute pour que la formule
mise a jour par propagateCustomFieldRename soit refletee dans le form
sans avoir a recharger la page.
2026-05-11 16:22:58 +02:00
Matthieu bc32648918 fix(custom-fields) : supporte les caracteres accentues dans les placeholders de formule
La regex \w+ ne capturait pas les caracteres accentues (ex. {Diametre}
avec 'è'), le placeholder restait litteral dans la reference auto.
Remplace par [^}]+ avec le flag u/gu cote PHP et JS pour matcher
n'importe quel caractere entre les accolades.
2026-05-11 16:22:52 +02:00
Matthieu 9027917ea2 fix(custom-fields) : propage le renommage d'un champ dans la formule de reference auto 2026-05-11 16:22:28 +02:00
67 changed files with 3847 additions and 458 deletions
+85 -215
View File
@@ -1,9 +1,7 @@
# CLAUDE.md — Inventory Project
## Project Overview
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
@@ -15,262 +13,134 @@ Mono-repo avec backend Symfony et frontend Nuxt en submodule git.
| Frontend | Nuxt (SPA, SSR off) | 4 |
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 |
| CSS | TailwindCSS 4 + DaisyUI 5 | |
| Auth | Session-based (cookies, pas JWT) | |
| Auth | Session-based (cookies, **pas JWT**) | |
| Containers | Docker Compose | |
## Glossaire Métier
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.
## Documentation détaillée (lire à la demande, ne pas dupliquer ici)
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
```
Inventory/ # Backend Symfony (repo principal)
├── src/Entity/ # Entités Doctrine (annotations PHP 8 attributes)
│ └── Trait/ # CuidEntityTrait (génération d'ID CUID)
├── src/Controller/ # Controllers custom (session, comments, audit…)
├── src/EventSubscriber/ # Audit subscribers (onFlush)
├── src/Service/ # Services métier (sync, conversion, storage…)
├── src/Enum/ # Enums PHP (DocumentType, ModelCategory)
├── src/DTO/ # Data Transfer Objects (sync workflow)
── src/Filter/ # Filtres API Platform custom
├── src/Command/ # Commandes Symfony CLI (compress-pdf, create-profile…)
├── 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)
├── src/Entity/ (+ Trait/) # Entités Doctrine (attributs PHP 8), CuidEntityTrait
├── src/Controller/ # Controllers custom (session, comments, audit, structure…)
├── src/EventSubscriber/ # Audit (onFlush) + sync/contraintes
├── src/Service/ (+ Sync/) # Services métier (sync, conversion, storage, versions…)
├── src/Enum/ src/DTO/ src/Filter/ src/Command/
├── config/ migrations/ docker/ scripts/ fixtures/ tests/
├── makefile VERSION # VERSION = source unique de version (semver)
── frontend/ # ← Frontend Nuxt (MÊME repo, pas un submodule)
└── app/{pages,components,composables,shared,middleware,services}/
```
## Key Commands
```bash
# Docker
make start # Démarrer les containers
make stop # Arrêter
make shell # Shell interactif (nécessite un TTY)
make install # Install complet (composer + npm + build)
make start / make stop # Démarrer / arrêter les containers
make shell # Shell interactif (nécessite un TTY)
make install # Install complet (composer + npm + build)
# Backend
make test # PHPUnit (tous les tests)
make test FILES=tests/Api/Entity/MachineTest.php # Un test spécifique
make php-cs-fixer-allow-risky # Linter PHP (cs-fixer)
make test # PHPUnit (tous)
make test FILES=tests/Api/Entity/MachineTest.php # Un test
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
# Frontend (dans frontend/)
npm run dev # Dev server (port 3001)
npm run build # Build production
npm run lint:fix # ESLint fix
npx nuxi typecheck # TypeScript check (0 errors attendu)
npm run dev # Dev server (port 3001)
npm run lint:fix # ESLint fix
npx nuxi typecheck # TypeScript check (0 erreur attendu)
# Database / Fixtures
make db-reset # Reset database (drop + recreate schema)
make fixtures-dump # Dump la DB vers fixtures/data.sql
make fixtures-load # Charger les fixtures SQL (désactive FK)
make fixtures-reset # Reset DB + recharger fixtures
make import-data # Importer les dumps SQL normalisés
make cache-clear # Clear cache Symfony
make db-reset # Reset DB (drop + recreate schema)
make fixtures-reset # Reset DB + recharger fixtures SQL
make import-data # Importer les dumps SQL normalisés
make cache-clear
# 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
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs --force # applique
# Release
./scripts/release.sh patch # Bump patch version (ou minor/major)
./scripts/release.sh patch # Bump version (patch/minor/major)
```
## Git Conventions
### Branches
- `master` — production
- `develop` — branche principale de dev (cible des PR)
- `feat/xxx`, `fix/xxx`, `refactor/xxx` — branches de travail
- **Branches** : `master` (prod), `develop` (cible des PR), `feat/* fix/* refactor/*`.
- **Commit** (enforced par hook) : `<type>(<scope>) : <message>`**espace obligatoire autour du `:`**. Types : `build chore ci docs feat fix perf refactor revert style test wip`.
- Ex : `feat(auth) : add login page`, `fix(machines) : prevent null crash`
- **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)
```
<type>(<scope optionnel>) : <message>
```
**Espace obligatoire autour du `:`**. Types autorisés (minuscules) :
`build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`, `wip`
## Pièges & patterns non-évidents
Exemples :
- `feat(auth) : add login page`
- `fix(machines) : prevent null crash on skeleton creation`
> Le catalogue complet est dans `docs/BACKEND.md` / `docs/FRONTEND.md`. Ci-dessous **uniquement** ce qui n'est pas évident en lisant le code.
### Pre-commit Hook
1. php-cs-fixer sur les fichiers PHP stagés
2. PHPUnit — bloque le commit si tests échouent
### Submodule Workflow
Le frontend est un submodule git. Lors d'un commit frontend :
1. Commit dans `frontend/` d'abord
2. Commit dans le repo principal pour mettre à jour le pointeur submodule
3. Push les deux repos
## Architecture Backend
### Entités Principales
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
#### 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
```
### Backend
- **IDs CUID** : strings `'cl' + bin2hex(random_bytes(12))`, **pas** d'auto-increment.
- **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`.
- **Audit** : subscribers Doctrine `onFlush` (diff + snapshot complet).
- **Migrations** : raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` (idempotence).
- **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`.
- **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`).
- **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).
### PostgreSQL — ATTENTION
- Les noms de colonnes sont **TOUJOURS EN MINUSCULES** dans PG
- Doctrine utilise camelCase (`typePieceId`) mais PG stocke `typepieceid`
- Le SQL brut doit utiliser les noms lowercase
- Tables de jointure many-to-many : colonnes `a` et `b` (ex: `_piececonstructeurs`)
- Noms de colonnes **TOUJOURS EN MINUSCULES** en PG. Doctrine camelCase (`typePieceId`) → PG `typepieceid`. Le **SQL brut doit être lowercase**.
- Tables de jointure M2M : colonnes `a` et `b` (ex : `_piececonstructeurs`).
## Architecture Frontend
### Patterns
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
- **Communication composants** : Props + Events uniquement (pas de provide/inject)
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session
- **⚠️ 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/...`).
- **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`
### Frontend
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`.
- **Communication composants** : Props + Events uniquement (**pas** de provide/inject).
- **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/...`).
- **Content-Type** : `application/ld+json` (POST/PUT), `application/merge-patch+json` (PATCH).
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`. Permissions : `usePermissions.ts` (miroir de la hiérarchie backend).
- **Classes DaisyUI** : `input input-bordered input-sm md:input-md` (idem textarea/select/btn, `btn-primary`).
## Règles Importantes
### CLAUDE.md — Maintenance obligatoire
- **Toujours consulter** ce fichier en début de conversation pour respecter les conventions
- **Mettre à jour** ce fichier quand une nouvelle convention, pattern ou décision architecturale est établie
- **Utiliser comme source de vérité** pour les commandes, patterns et règles du projet
### 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
### Avant de modifier du code
1. **Lire le fichier** avant de l'éditer.
2. **Reproduire le pattern existant** (noms, indentation, structure).
3. **Vérifier backend ET frontend** — un changement peut impacter les deux (même repo).
### Après chaque modification
1. Backend PHP : `make php-cs-fixer-allow-risky`
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
- Ajouter des features non demandées, du code mort, ou des abstractions prématurées
- Utiliser `provide/inject` le codebase utilise Props + Events
- Utiliser JWT/tokens — l'auth est session-based
- Écrire du SQL avec des noms camelCase — PostgreSQL = lowercase
- Committer sans que l'utilisateur le demande explicitement
- Force push sans confirmation explicite
- Modifier la config git
- Features non demandées, code mort, abstractions prématurées
- `provide/inject` (le code utilise Props + Events) · JWT/tokens (auth session-based)
- SQL en camelCase (PG = lowercase)
- Committer sans demande explicite · force push sans confirmation · modifier la config git
### Submodule — Synchronisation
Quand les branches `master` et `develop` divergent sur l'un des deux repos, **toujours les synchroniser** :
- Main repo : `git checkout master && git merge develop && git push`
- Frontend : `git checkout develop && git merge master && git push` (ou l'inverse selon le cas)
### Maintenir ce fichier
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/`.
## Tests
### Stack de test
- **PHPUnit 12** + **API Platform Test** (`ApiTestCase`)
- **DAMA DoctrineTestBundle** — wrappe chaque test dans une transaction avec rollback automatique (pas de TRUNCATE)
- Base de test : même PG, env `test`
### 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()`
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
- **PHPUnit 12** + **API Platform Test** (`ApiTestCase`), env `test`, même PG.
- **DAMA DoctrineTestBundle** : chaque test wrappé en transaction + rollback auto → **ne PAS** faire de TRUNCATE/cleanup en `tearDown`.
- Hériter de `AbstractApiTestCase` (helpers auth + factories `create*()`).
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`.
## URLs Locales
- API Symfony : `http://localhost:8081/api`
- Nuxt dev : `http://localhost:3001`
- Adminer (PG) : `http://localhost:5050`
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
- API Symfony : `http://localhost:8081/api` · Nuxt dev : `http://localhost:3001`
- Adminer : `http://localhost:5050` · PG direct : `localhost:5433` (user/pass `root`, db `inventory`)
## Delegation Codex
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.
## 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.
+434
View File
@@ -0,0 +1,434 @@
# Review complète — Projet Inventory
> Audit éducatif du projet. Chaque point explique le problème, pourquoi c'est un problème, et comment le corriger.
> Document généré le 2026-06-11 (branche `develop`, v1.9.47).
> Complément de `docs/REVIEW_ARCHITECTURE.md` (2026-03-23) — les findings d'architecture n'y sont pas dupliqués, seul leur **statut** est mis à jour ici (§4).
---
## Table des matières
1. [Sécurité](#1-sécurité)
2. [Bugs fonctionnels](#2-bugs-fonctionnels)
3. [Code mort et violations des règles projet](#3-code-mort-et-violations-des-règles-projet)
4. [Dette d'architecture — suivi REVIEW_ARCHITECTURE.md](#4-dette-darchitecture--suivi-review_architecturemd)
5. [Documentation et configuration](#5-documentation-et-configuration)
6. [Frontend et UX](#6-frontend-et-ux)
7. [CI/CD et dépendances](#7-cicd-et-dépendances)
8. [Hygiène git](#8-hygiène-git)
9. [Bonnes pratiques à retenir](#9-bonnes-pratiques-à-retenir)
---
## 1. Sécurité
### 1.1 CRITIQUE — Credentials de production commités dans `.mcp.json`
**Fichier :** `.mcp.json` (tracké dans git, **pas** dans `.gitignore`)
Le fichier contient le mot de passe du profil admin MCP de production (`X-Profile-Password: A123` pour `inventory.malio-dev.fr`) **et** un bearer token Lesstime valide (`project.malio-dev.fr`). Toute personne ayant accès au dépôt (ou à son historique, même après suppression du fichier) peut s'authentifier sur les deux systèmes de production.
**Pourquoi c'est grave :** un secret commité reste dans l'historique git pour toujours. Le token Lesstime donne accès à tout le système de gestion de projet (tâches, temps, absences). Le mot de passe `A123` est en plus trivial.
**Correction :**
1. **Révoquer/changer** les deux secrets (mot de passe du profil MCP + régénérer le token Lesstime) — c'est l'étape la plus importante, supprimer le fichier ne suffit pas.
2. `git rm --cached .mcp.json` + ajouter `.mcp.json` au `.gitignore`.
3. Conserver un `.mcp.json.example` avec des placeholders.
### 1.2 CRITIQUE — `create_test_user.php` tracké à la racine avec credentials admin en clair
**Fichier :** `create_test_user.php:16,59-61` (tracké dans git)
Script de debug qui crée un compte `ROLE_ADMIN` avec `admin@admin.com` / `admin123` (hardcodé et affiché en clair sur stdout), connexion PDO brute `root`/`root`. Posé à la racine du projet, il est embarquable dans une image de prod et exécutable partout où `vendor/` existe.
**Pourquoi c'est grave :** si ce script tourne (ou a tourné) en production, il existe un compte admin avec un mot de passe devinable en 3 essais. Les credentials sont aussi dans l'historique git.
**Correction :** supprimer le fichier du dépôt (`git rm`), vérifier **en prod** qu'aucun profil `admin@admin.com` actif n'existe. Le besoin légitime (seed d'un admin de dev) est déjà couvert par `fixtures/` ou peut devenir une commande Symfony `app:create-admin` qui demande le mot de passe en argument.
### 1.3 IMPORTANT — Mot de passe de la base de prod hardcodé dans 9 scripts trackés
**Fichiers :** `scripts/check-prod-values.php`, `scripts/fix-prod-all.php`, `scripts/restore-custom-field-values.php`, `scripts/migrate-orphaned-custom-fields.php`, `scripts/check-prod-audit-dates.php`, `scripts/check-prod-missing-piece-cfs.php`, `scripts/check-prod-orphaned-detail.php`, `scripts/fix-prod-recreate-and-migrate.php`, `scripts/verify-prod-health.php`
Neuf scripts de réparation one-shot contiennent le couple `ferme_user`/`fermerecette` en dur — des credentials de base de données de production, dans le dépôt.
**Pourquoi c'est grave :** même problème que 1.1 — secret en clair dans l'historique. En plus ces scripts contournent Doctrine et l'audit : les exécuter par erreur modifie la prod sans trace.
**Correction :** changer le mot de passe PG concerné, puis archiver/supprimer ces scripts (ils ont déjà servi). S'ils doivent rester, lire les credentials depuis l'environnement (`getenv('DATABASE_URL')`) et les déplacer dans `_archives/` (déjà gitignoré).
### 1.4 IMPORTANT — Aucune limite de taille d'upload (ni applicative, ni infra)
**Fichiers :** `src/State/DocumentUploadProcessor.php:55-116`, `src/Controller/CommentController.php:104-137`, `infra/dev/php.ini`, `infra/prod/nginx.conf`
Aucun des deux chemins d'upload ne vérifie `$file->getSize()`. Côté infra : `php.ini` ne définit ni `upload_max_filesize` ni `post_max_size` (défauts PHP : 2 Mo / 8 Mo), et `nginx.conf` prod n'a pas de `client_max_body_size` (défaut nginx : **1 Mo**).
**Pourquoi c'est un problème (double) :**
- *Fonctionnel* : en prod, tout upload > 1 Mo est probablement rejeté par nginx avec une erreur 413 brute (non gérée par le front) — alors que l'app est censée stocker des PDF techniques.
- *Sécurité* : aucune limite **choisie** n'existe ; le jour où quelqu'un monte les limites infra "pour faire passer un gros PDF", plus rien ne protège le disque (un `ROLE_VIEWER` peut uploader via les commentaires, cf. 1.9).
**Correction :** décider d'une limite métier (ex. 50 Mo), puis l'appliquer aux 3 niveaux :
1. Check applicatif `if ($file->getSize() > self::MAX_UPLOAD_BYTES)` dans les deux chemins (erreur 400 propre).
2. `upload_max_filesize = 50M` / `post_max_size = 55M` dans `infra/dev/php.ini` **et** l'image prod.
3. `client_max_body_size 55m;` dans `infra/prod/nginx.conf`.
### 1.5 MOYEN — Garde anti path-traversal incomplet dans `DocumentStorageService`
**Fichier :** `src/Service/DocumentStorageService.php:28-42`
`getAbsolutePath()` vérifie `str_contains($relativePath, '..')` puis compare `realpath()` au répertoire de stockage — mais `realpath()` renvoie `false` pour un fichier inexistant, donc le second contrôle est **sauté** dans ce cas. Un chemin absolu (`/etc/passwd`) passe le premier contrôle (pas de `..`) : `$this->storageDir.'/'.'/etc/passwd'`… ne résout pas vers `/etc/passwd`, mais un chemin via symlink dans le storage le pourrait. L'exploitation exige d'écrire `document.path` en base (pas d'input direct utilisateur), donc le risque actuel est faible — c'est du **hardening**.
**Correction :** valider sur le répertoire parent, qui existe toujours :
```php
$absolutePath = $this->storageDir.'/'.$relativePath;
$realParent = realpath(dirname($absolutePath));
if (false === $realParent || !str_starts_with($realParent.'/', realpath($this->storageDir).'/')) {
throw new RuntimeException(sprintf('Path traversal detected: "%s"', $relativePath));
}
```
### 1.6 MOYEN — Pas de protection CSRF : `SameSite=Lax` est la seule barrière
**Fichiers :** `config/packages/framework.yaml:8`, `config/packages/nelmio_cors.yaml`
L'auth est par cookie de session, et aucun endpoint d'écriture ne vérifie de token CSRF ni de header custom. La protection repose à 100 % sur `cookie_samesite: lax` (et le CORS pour les lectures cross-origin). C'est la posture courante pour une SPA en 2026, mais c'est une **défense à un seul étage** : un navigateur ancien/exotique ou une config future en sous-domaine partagé la ferait tomber. À noter : `csrfToken` existe dans `nuxt.config.ts:59` mais n'est branché nulle part (config morte, cf. 3.1).
**Correction (peu coûteuse) :** exiger un header `X-Requested-With: XMLHttpRequest` sur les méthodes non-GET du firewall `api` (un listener de 15 lignes) — un formulaire HTML cross-site ne peut pas envoyer ce header. L'ajouter dans `useApi.ts` côté front. Supprimer le `csrfToken` mort.
### 1.7 MOYEN — `session_fixation_strategy: none` désactive la protection globalement
**Fichier :** `config/packages/security.yaml:5`, mitigé par `src/Controller/SessionProfileController.php:96`
Le choix est documenté (le `migrate` par défaut casse les requêtes concurrentes de la SPA) et le login appelle bien `$session->migrate(true)` manuellement — le flux actuel est correct. Le risque est **futur** : tout nouveau chemin d'authentification (reset de mot de passe, impersonation…) n'aura pas la régénération d'ID de session, silencieusement.
**Correction :** garder le réglage, mais l'encadrer : un test fonctionnel qui vérifie que l'ID de session change au login (échouera si quelqu'un retire le `migrate(true)`), et un commentaire dans `SessionProfileController` pointant vers `security.yaml`.
### 1.8 MOYEN — MCP HTTP : mot de passe en clair dans les headers à chaque requête
**Fichier :** `src/Mcp/Security/McpHeaderAuthenticator.php:43-44`, `infra/prod/nginx-proxy.conf`
Chaque requête MCP porte `X-Profile-Password` en clair. Les headers transitent par le proxy nginx et peuvent finir dans des logs (proxy, APM, outils de debug). Le rate-limiting et le hash côté serveur sont bien faits, mais le secret circule en permanence — et l'URL configurée dans `.mcp.json` est en **`http://`** (pas de TLS).
**Correction :** passer à un token d'API dédié (longue chaîne aléatoire, stockée hashée, comparée via le hasher existant), transmis en `Authorization: Bearer` — comme le fait déjà Lesstime. Et servir `/_mcp` uniquement en HTTPS.
### 1.9 MINEUR — Création de commentaires + upload de fichiers ouverte à `ROLE_VIEWER`
**Fichier :** `src/Controller/CommentController.php:33`
Convention du projet : lecture = `ROLE_VIEWER`, écriture = `ROLE_GESTIONNAIRE`. La création de commentaire (avec pièces jointes !) est la seule écriture accessible aux viewers. Si c'est un choix métier (« tout le monde peut commenter »), OK — mais combiné à 1.4, un compte en lecture seule peut remplir le disque.
**Correction :** confirmer le choix métier et le documenter dans `docs/BACKEND.md` ; appliquer la limite de taille de 1.4 dans tous les cas.
### 1.10 MINEUR — `download()` sans les headers de protection de `serve()`
**Fichier :** `src/Controller/DocumentServeController.php:105-116`
`serve()` envoie `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox` (très bien) ; `download()` n'envoie ni l'un ni l'autre pour les fichiers disque. La disposition `attachment` protège déjà beaucoup, mais l'asymétrie est gratuite.
**Correction :** copier les deux headers dans `download()`.
### 1.11 MINEUR — Pagination max très élevée sur certaines ressources
**Fichiers :** `src/Entity/Constructeur.php:47` (2000), `src/Entity/ConstructeurCategorie.php:43` (1000), `src/Entity/Document.php:55` (500)
Un `?itemsPerPage=2000` charge 2000 entités + sérialisation en une requête. Pour des catalogues internes c'est sans doute volontaire (dropdowns sans pagination), mais c'est aussi un vecteur de charge facile.
**Correction :** vérifier ce que le front demande réellement et redescendre au besoin réel (ou documenter pourquoi 2000).
### 1.12 MINEUR — `infra/dev/.env.docker.local` tracké malgré le `.gitignore`
**Fichier :** `infra/dev/.env.docker.local:27,34`
Le fichier est dans `.gitignore` (ligne 23) **mais déjà tracké** (ajouté avant la règle — gitignore n'agit pas sur les fichiers déjà suivis). Secrets de dev faibles (`changeme_…`) : sans gravité en soi, mais le fichier est censé être local et chaque dev qui le modifie crée du diff.
**Correction :** `git rm --cached infra/dev/.env.docker.local`, fournir `infra/dev/.env.docker.local.example` (référencé par le README au passage).
---
## 2. Bugs fonctionnels
### 2.1 IMPORTANT — Fallback `http://localhost:3000` dans `useApi.ts`
**Fichier :** `frontend/app/composables/useApi.ts:18`
```ts
const API_BASE_URL = (publicConfig.apiBaseUrl as string) || 'http://localhost:3000'
```
Si `NUXT_PUBLIC_API_BASE_URL` est vide au build/runtime de prod, **tous** les appels API partent silencieusement vers `localhost:3000` (le port du dev server, qui ne sert même pas l'API). Échec garanti mais difficile à diagnostiquer.
**Correction :** fallback relatif `'/api'`… attention, `useApi` préfixe déjà `/api` lui-même — le bon fallback est donc `''` (origine courante) :
```ts
const API_BASE_URL = (publicConfig.apiBaseUrl as string) || ''
```
Même nettoyage pour `nuxt.config.ts:49` (`http://localhost/api`, valeur SSR jamais utilisée puisque `ssr: false`).
### 2.2 IMPORTANT — Uploads de prod probablement plafonnés à 1 Mo par nginx
Voir 1.4 — c'est le versant fonctionnel : sans `client_max_body_size`, nginx rejette en 413 tout body > 1 Mo, et le front n'affiche pas d'erreur claire (le toast générique de `useApi` au mieux). À tester en prod avec un PDF de 5 Mo ; si les uploads passent, c'est qu'une config existe ailleurs et il faut l'aligner dans le dépôt.
### 2.3 MOYEN — Deux mécanismes de maintenance déconnectés
**Fichiers :** `src/Controller/MaintenanceController.php:56` (flag `var/maintenance`), `infra/prod/nginx.conf:6` (flag `maintenance.on`), `infra/prod/deploy.sh:47`
Le toggle admin (`PUT /api/admin/maintenance`) écrit `var/maintenance`, lu par le middleware front — maintenance **applicative**. Le déploiement crée `maintenance.on`, lu par nginx — maintenance **infra**. Les deux coexistent volontairement mais rien ne le documente : un admin qui active la maintenance via l'UI ne bloque pas les appels API directs (le flag n'est vérifié que par le middleware front), et inversement.
**Correction :** documenter les deux niveaux dans `DEPLOY.md` ; idéalement faire vérifier le flag applicatif côté backend (listener kernel.request qui renvoie 503 pour les non-admins) plutôt que de ne compter que sur le middleware front (contournable).
### 2.4 MOYEN — Provider Symfony par `email` alors que `email` est nullable
**Fichiers :** `config/packages/security.yaml:18`, `src/Entity/Profile.php:59-61`, `src/Controller/AdminProfileController.php:66`
On peut créer un profil sans email (profils « kiosque »), mais le user provider charge par `property: email`. Ça marche aujourd'hui parce que `SessionProfileAuthenticator` charge par ID — le provider n'est jamais utilisé pour ces profils. Incohérence latente : tout futur usage du provider standard (remember-me, impersonation, commande console) cassera sur ces profils.
**Correction :** soit rendre l'email obligatoire et générer un email technique pour les kiosques, soit écrire un `UserProviderInterface` custom qui charge par id **ou** email, et le déclarer dans `security.yaml`.
---
## 3. Code mort et violations des règles projet
### 3.1 MINEUR — Config morte dans `nuxt.config.ts`
**Fichier :** `frontend/nuxt.config.ts:56-59`
`csrfToken`, `requestTimeout`, `enableDebug`, `enableAnalytics`, `logLevel` : définis, jamais consommés (seul `apiTimeout` est lu par `useApi.ts`). Du code mort en config laisse croire que des fonctionnalités existent (un lecteur pense que le CSRF est géré — il ne l'est pas, cf. 1.6).
**Correction :** supprimer ces 5 clés.
### 3.2 MINEUR — Variables JWT dans un projet 100 % session
**Fichier :** `infra/dev/.env.docker.local:28-30`
`JWT_SECRET_KEY`, `JWT_PUBLIC_KEY`, `JWT_PASSPHRASE` — copiées d'un autre projet (Lesstime/Starseed utilisent JWT, pas Inventory). Le CLAUDE.md martèle « pas JWT » ; ces variables sèment le doute.
**Correction :** supprimer les 3 lignes.
### 3.3 MOYEN — `@nuxtjs/tailwindcss` : dépendance inutilisée et conflictuelle
**Fichier :** `frontend/package.json:21`
Le projet utilise Tailwind 4 via `@tailwindcss/vite` (`nuxt.config.ts:1,64`). `@nuxtjs/tailwindcss` n'est référencé nulle part et installe son propre Tailwind **v3** dans `node_modules` — bloat + risque de résolution ambiguë.
**Correction :** `npm uninstall @nuxtjs/tailwindcss` puis vérifier `npm run build`.
### 3.4 MINEUR — Dockerfile dev pollué par un template générique
**Fichier :** `infra/dev/Dockerfile:48-53,82-100`
Blocs commentés Oracle OCI8, IMAP/Kerberos, PDO MySQL/SQLite — aucun rapport avec un projet PostgreSQL. Bruit pur.
**Correction :** supprimer les blocs commentés.
### 3.5 MINEUR — `node_modules/` orphelin à la racine backend
Pas de `package.json` à la racine, mais un `node_modules/` (untracked) y traîne, et `node_modules/` n'est pas dans le `.gitignore` racine — seul le hasard l'empêche d'être commité un jour.
**Correction :** `rm -rf node_modules/` à la racine + ajouter `/node_modules/` au `.gitignore`.
### 3.6 MINEUR — 3 fichiers utils restés en `.js` non typés
**Fichiers :** `frontend/app/utils/documentPreview.js`, `frontend/app/utils/fileIcons.js`, `frontend/app/utils/printTemplates/machineReport.js`
Importés depuis du `.ts` sans aucune sécurité de type — incohérent avec la règle « TypeScript, 0 erreur typecheck ».
**Correction :** renommer en `.ts` et typer les signatures (mécanique, bon candidat Codex).
---
## 4. Dette d'architecture — suivi REVIEW_ARCHITECTURE.md
État des 10 chantiers identifiés le 2026-03-23, vérifié ce jour : **1 corrigé sur 10**, et le God controller a grossi.
| # | Source de complexité | Statut 2026-06-11 |
|---|---------------------|-------------------|
| 1 | `smartMatch` dupliqué dans les Sync Strategies | ❌ Toujours dupliqué (`ComposantSyncStrategy.php:380`, `PieceSyncStrategy.php:244`) |
| 2 | Custom Fields : 4-6 FK nullable (polymorphisme pauvre) | ❌ Inchangé, pas de contrainte CHECK |
| 3 | Composables géants | ⚠️ Partiel : `useComponentEdit.ts` 539 LOC, `usePieceEdit.ts` 404, `useComponentCreate.ts` 366 |
| 4 | Triple duplication utils custom fields | ✅ **Corrigé** — fusionné dans `shared/utils/customFields.ts` |
| 5 | `pendingStructure` canal caché | ❌ Toujours sans `try/finally` (`ModelTypeProcessor.php`) |
| 6 | `PieceProductSyncSubscriber` legacy | ❌ Inchangé (`recomputeSingleEntityChangeSet` toujours là) |
| 7 | Double flush dans les processors | ❌ Inchangé (`ComposantProcessor.php:45,132`) |
| 8 | `MachineStructureController` God controller | ❌ **Aggravé** : 300+ → **1121 lignes** |
| 9 | Dépendance circulaire `useMachineDetailData` | ❌ Proxy ref toujours en place (`:133`) |
| 10 | Typage `any` systématique | ❌ **179 occurrences** dans 26 composables |
**Le point clé :** la Phase 1 « quick wins » du plan (items 1, 5, 6, 7 — effort S chacun, sans impact d'interface) n'a pas été entamée en presque 3 mois, alors que ce sont les corrections au meilleur ratio risque/bénéfice du projet. Le `MachineStructureController` qui grossit de +150 lignes confirme la trajectoire : sans extraction de services, chaque feature l'alourdit.
---
## 5. Documentation et configuration
### 5.1 IMPORTANT — Toute la doc décrit encore un submodule qui n'existe plus
**Fichiers :** `README.md:49-50,291-296`, `DEPLOY.md:58,62,208`, `RELEASE.md:49,117`, `frontend/README.md:150-154`
Le frontend a été intégré au monorepo (cf. CLAUDE.md « plus de submodule »), mais le README explique `git clone --recurse-submodules`, un workflow de commit en deux temps, et DEPLOY/RELEASE font des `git submodule update`. Un nouveau dev qui suit le README perd du temps sur des commandes sans effet ; un déploiement scripté depuis DEPLOY.md exécute des étapes mortes.
**Correction :** purger toute mention de submodule des 4 fichiers, décrire le workflow monorepo (1 commit racine).
### 5.2 IMPORTANT — `DEPLOY.md` utilise l'utilisateur PG d'un autre projet
**Fichier :** `DEPLOY.md` (6 occurrences de `ferme_user`)
Les commandes psql/backup de DEPLOY.md utilisent `ferme_user`/`fermerecette` — copié-collé du projet **Ferme**. Le vrai user prod est `inventory_user` (cf. `infra/prod/.env.example`). Quelqu'un qui suit la doc en incident de prod tape des commandes qui échouent (ou pire, sur la mauvaise base si les deux co-habitent sur l'instance partagée).
**Correction :** remplacer les 6 occurrences par `inventory_user` et des placeholders de mot de passe.
### 5.3 MOYEN — `RELEASE.md` et `CLAUDE.md` référencent un fichier `VERSION` inexistant
**Fichiers :** `RELEASE.md:17,50,80,82`, `CLAUDE.md` (arbre projet)
La version vit dans `config/version.yaml` (1.9.47) — le fichier `VERSION` n'existe plus. `scripts/release.sh` et la CI (`auto-tag-develop.yml`) sont à jour, mais la doc décrit l'ancien système, y compris « `frontend/nuxt.config.ts` lit `VERSION` au build ».
**Correction :** mettre à jour RELEASE.md et la ligne d'arbre dans CLAUDE.md vers `config/version.yaml`.
### 5.4 MINEUR — Pas de `.env.example` backend ni de `.env.docker.local.example`
Un nouveau dev n'a aucun modèle des variables attendues côté backend (le README pointe vers `infra/dev/.env.docker.local`… qui est censé être local/ignoré, cf. 1.12).
**Correction :** créer `infra/dev/.env.docker.local.example` avec placeholders.
### 5.5 MINEUR — Fichiers de données métier à la racine du projet
`Company (1).json`, `customer.json`, `customer.original.json`, `Ensemble simple rotor.pdf`, `inventory_prod (2).sql.gz` traînent à la racine (untracked — le `.sql.gz` est protégé par le gitignore, **pas les `.json` ni le `.pdf`** : un `git add .` distrait les commiterait, et `customer.json` ressemble à des données client réelles → RGPD).
**Correction :** déplacer hors du dépôt (ex. `~/imports/`), et ajouter une règle défensive au `.gitignore` (`/*.json` racine, `/*.pdf`).
---
## 6. Frontend et UX
### 6.1 MOYEN — Erreurs avalées : `console.error` sans feedback utilisateur
**Fichiers :** 57+ occurrences ; ex. `useMachineDetailData.ts:372,385`, `useProfileSession.ts:27`
Le pattern dominant en cas d'échec API est `console.error(...)` puis on continue avec `null`/`[]`. `useApi` toaste déjà les erreurs HTTP, donc une partie est couverte — mais les erreurs réseau/parsing et les branches qui catchent **avant** le toast laissent l'utilisateur devant une page partiellement vide sans explication, et créent du double-reporting ailleurs.
**Correction :** définir une règle unique : `useApi` est le seul à toaster ; les composables ne re-loggent pas, mais positionnent un état d'erreur (`error.value = ...`) que la page affiche (bandeau « Impossible de charger X — Réessayer »).
### 6.2 MOYEN — Appel `/maintenance/check` à chaque navigation
**Fichier :** `frontend/app/middleware/profile.global.ts:34-39`
Chaque changement de route d'un non-admin déclenche un aller-retour API. Latence ajoutée à **toutes** les navigations pour un état qui change une fois par an.
**Correction :** cacher le résultat dans un `useState` avec TTL (ex. 60 s) ; le 503 éventuel d'un appel API normal peut aussi servir de signal.
### 6.3 Rappels (déjà au §4)
`any` ×179, composables géants, dépendance circulaire — voir tableau §4 et `docs/REVIEW_ARCHITECTURE.md` pour les solutions détaillées.
---
## 7. CI/CD et dépendances
### 7.1 IMPORTANT — Aucun garde-fou automatisé : la CI ne lance ni tests ni lint
**Fichiers :** `.gitea/workflows/auto-tag-develop.yml`, `.gitea/workflows/build-docker.yml`, workflow de commit (CLAUDE.md : « committer avec `--no-verify` »)
La chaîne actuelle : push sur `develop` → auto-tag → build Docker → image prod. **Aucune étape ne lance PHPUnit, php-cs-fixer, ESLint ou `nuxi typecheck`.** Et comme le hook pre-commit (qui devait jouer ce rôle) est trop lent, la convention projet est de le contourner avec `--no-verify`. Résultat : il est possible de tagger et construire une image de prod avec des tests rouges sans qu'aucun système ne le signale.
**Pourquoi c'est le finding process le plus important de cette review :** la suite de tests est bonne (48 fichiers, DAMA, factories) — mais une suite de tests qui ne tourne pas en CI ne protège rien.
**Correction :** ajouter un workflow `ci.yml` déclenché sur PR + push develop :
1. `composer install` + `php-cs-fixer --dry-run` + PHPUnit (avec un service PG 16).
2. `npm ci` + `eslint` + `npx nuxi typecheck` + `npm run build` dans `frontend/`.
3. Faire dépendre `auto-tag-develop` du succès de la CI (ou au minimum bloquer les PR).
Le hook pre-commit lent peut alors être assumé comme optionnel.
### 7.2 MOYEN — Le build Docker de prod n'est pas testé avant le push
`build-docker.yml` build + push `latest` dès qu'un tag est posé — sans health-check de l'image (la CI de 7.1 règle l'essentiel ; un `docker run --rm image php bin/console about` est un bon smoke test bon marché).
### 7.3 Dépendances
- Backend : propre, à jour (Symfony 8.0.*, AP ^4.2, ORM ^3.6). `symfony/twig-bundle` à vérifier : aucun template Twig dans `templates/` — si seul le MCP bundle le requiert, le laisser en dépendance transitive.
- Frontend : `@nuxtjs/tailwindcss` à supprimer (cf. 3.3). Le reste est à jour et utilisé.
---
## 8. Hygiène git
Synthèse des fichiers trackés à tort (détails en §1/§5) :
| Fichier | Problème | Action |
|---------|----------|--------|
| `.mcp.json` | Credentials prod | `git rm --cached` + gitignore + **rotation** |
| `create_test_user.php` | Script debug + credentials | `git rm` |
| `scripts/{check,fix,restore,migrate,verify}-prod-*.php` (9) | Mot de passe PG prod | rotation + archive/suppression |
| `infra/dev/.env.docker.local` | Censé être local, déjà tracké | `git rm --cached` + `.example` |
| `.claude/settings.json` | Inoffensif (config plugins) | OK, peut rester |
Gaps `.gitignore` racine : `/node_modules/`, `.mcp.json`, `/*.json` (défensif), `/*.pdf`.
> Note : contrairement au premier rapport d'agent, `.claude/settings.local.json` n'est **pas** tracké — pas d'action nécessaire.
---
## 9. Bonnes pratiques à retenir
### Ce qui est bien fait dans le projet
- **`declare(strict_types=1)` partout**, attributs PHP 8 modernes, code backend homogène et lisible.
- **Sécurité API systématique** : chaque opération API Platform porte son `security:`, chaque controller custom ouvre par `denyAccessUnlessGranted()` — aucun endpoint accidentellement public trouvé.
- **Rate limiting** sur le login **et** sur l'auth MCP (souvent oublié ailleurs).
- **IDs CUID** : pas d'énumération séquentielle possible.
- **Upload : validation MIME par contenu** (`finfo`), nom de fichier régénéré (CUID), stockage hors de `public/` — et SVG exclu de l'allowlist (XSS évité).
- **`serve()` documents** : `nosniff` + `CSP: sandbox` — au-dessus du standard.
- **Système d'audit** propre (`AbstractAuditSubscriber` en template method, flag `skipAudit` réfléchi).
- **Tests solides** : 48 fichiers, `AbstractApiTestCase` avec factories, DAMA rollback, coûts de hash réduits en test.
- **Le refacto `customFields.ts` a été fait** — la dette de REVIEW_ARCHITECTURE n'est pas ignorée, juste lente.
- **Docker prod multi-stage** propre, page maintenance nginx, volumes nommés pour le storage.
- **Zéro `console.log`**, zéro TODO/FIXME oublié dans `src/` et `frontend/app/`.
### Les règles à graver
1. **Un secret commité est un secret grillé** : la suppression du fichier ne suffit jamais, il faut **révoquer** (cf. `.mcp.json`, scripts prod).
2. **`.gitignore` n'agit pas sur les fichiers déjà trackés** — `git rm --cached` d'abord.
3. **Une suite de tests qui ne tourne pas en CI ne protège rien** : si le hook est trop lent pour être vécu, le garde-fou doit vivre en CI.
4. **Toute limite (taille, pagination, timeout) doit être choisie, pas héritée d'un défaut** — sinon c'est le défaut le plus bas de la chaîne qui décide (nginx 1 Mo).
5. **Un fallback doit échouer bruyamment ou être correct**`|| 'http://localhost:3000'` en prod est le pire des deux mondes.
6. **La doc copiée d'un autre projet est pire que pas de doc** (`ferme_user` dans DEPLOY.md).
7. **Config morte = mensonge** : un `csrfToken` non branché fait croire qu'une protection existe.
8. **Les scripts one-shot ont une date de péremption** : après usage → `_archives/` ou suppression.
9. **Quand un God controller existe, chaque feature le fait grossir** : extraire tôt (1121 lignes et ça monte).
10. **Les quick wins planifiés perdent leur valeur s'ils ne sont jamais faits** : la Phase 1 de REVIEW_ARCHITECTURE (4×effort S) attend depuis 3 mois.
---
## Résumé par priorité
| Priorité | # | Problème | Fichier |
|----------|---|----------|---------|
| **P0** | 1.1 | Credentials prod + token Lesstime commités (rotation requise) | `.mcp.json` |
| **P0** | 1.2 | Script admin avec mot de passe en clair tracké | `create_test_user.php` |
| **P0** | 1.3 | Mot de passe PG prod dans 9 scripts trackés | `scripts/*-prod-*.php` |
| **P1** | 7.1 | CI sans tests ni lint + hook contourné | `.gitea/workflows/` |
| **P1** | 1.4/2.2 | Aucune limite d'upload choisie (et défaut nginx 1 Mo) | `DocumentUploadProcessor.php`, `CommentController.php`, `infra/` |
| **P1** | 2.1 | Fallback API `localhost:3000` en prod | `frontend/app/composables/useApi.ts:18` |
| **P1** | 5.1 | Doc submodule obsolète (4 fichiers) | `README.md`, `DEPLOY.md`, `RELEASE.md`, `frontend/README.md` |
| **P1** | 5.2 | `ferme_user` dans DEPLOY.md | `DEPLOY.md` |
| **P1** | 1.5 | Garde path-traversal incomplet | `DocumentStorageService.php:28-42` |
| **P2** | 1.6 | CSRF : une seule barrière (SameSite) | `framework.yaml`, `useApi.ts` |
| **P2** | 1.7 | Session fixation : protection désactivée globalement | `security.yaml:5` |
| **P2** | 1.8 | Mot de passe MCP dans headers + HTTP sans TLS | `McpHeaderAuthenticator.php` |
| **P2** | 2.3 | Double mécanisme maintenance non documenté | `MaintenanceController.php`, `nginx.conf` |
| **P2** | 2.4 | Provider `email` vs email nullable | `security.yaml:18`, `Profile.php` |
| **P2** | 3.3 | `@nuxtjs/tailwindcss` inutilisé (TW3 vs TW4) | `frontend/package.json:21` |
| **P2** | 5.3 | Doc `VERSION` obsolète | `RELEASE.md`, `CLAUDE.md` |
| **P2** | 5.5 | Données client à la racine (`customer.json`…) | racine projet |
| **P2** | 6.1 | Erreurs avalées sans feedback UI | composables (57+) |
| **P2** | 6.2 | Maintenance check à chaque navigation | `profile.global.ts:34-39` |
| **P2** | 1.12 | `.env.docker.local` tracké malgré gitignore | `infra/dev/.env.docker.local` |
| **P3** | 1.9 | Commentaires+upload en ROLE_VIEWER (à confirmer métier) | `CommentController.php:33` |
| **P3** | 1.10 | Pagination max 2000/1000/500 | `Constructeur.php`, `Document.php` |
| **P3** | 1.11/3.2 | Secrets dev faibles + vars JWT mortes | `infra/dev/.env.docker.local` |
| **P3** | 3.1 | Config morte nuxt.config (5 clés) | `frontend/nuxt.config.ts:56-59` |
| **P3** | 3.4 | Dockerfile dev : blocs commentés Oracle/MySQL | `infra/dev/Dockerfile` |
| **P3** | 3.5 | `node_modules/` orphelin racine + gitignore | racine projet |
| **P3** | 3.6 | 3 fichiers `.js` non typés | `frontend/app/utils/` |
| **P3** | 1.10 | `download()` sans nosniff/CSP | `DocumentServeController.php:105-116` |
| **P3** | 5.4 | Pas de `.env.docker.local.example` | `infra/dev/` |
| **P3** | 7.2 | Image Docker poussée sans smoke test | `build-docker.yml` |
> La dette d'architecture (§4) a son propre plan dans `docs/REVIEW_ARCHITECTURE.md` — recommandation : exécuter enfin sa **Phase 1** (4 corrections effort S, sans impact d'interface).
+343
View File
@@ -0,0 +1,343 @@
# Tickets correctifs — Projet Inventory
> Liste de tâches issues de la review du 2026-06-11 (`REVIEW.md`).
> Chaque ticket est autonome : contexte, ce qu'il faut faire, fichiers concernés.
> Commence par les P0, puis P1, etc. Convention de commit : `fix(T-XXX) : description courte`.
---
## P0 — Urgents (sécurité)
### T-001 — Révoquer et retirer les credentials de `.mcp.json`
**Pourquoi :** le fichier `.mcp.json` est dans git et contient le mot de passe admin MCP de production (`A123`) et un token Lesstime valide. Toute personne avec accès au dépôt (ou à son historique) peut se connecter aux deux systèmes. Supprimer le fichier ne suffit pas : git garde l'historique — il faut **changer les secrets**.
**À faire :**
1. Changer le mot de passe du profil `admin-default-profile` sur `inventory.malio-dev.fr` (et choisir un vrai mot de passe, pas `A123`).
2. Régénérer le bearer token Lesstime côté Lesstime.
3. Sortir le fichier de git sans le supprimer du disque :
```bash
git rm --cached .mcp.json
echo ".mcp.json" >> .gitignore
```
4. Créer `.mcp.json.example` avec des placeholders :
```json
{
"mcpServers": {
"inventory": {
"type": "http",
"url": "https://inventory.malio-dev.fr/_mcp",
"headers": {
"X-Profile-Id": "<PROFILE_ID>",
"X-Profile-Password": "<PASSWORD>"
}
}
}
}
```
5. Remettre les nouveaux secrets dans ton `.mcp.json` local (désormais ignoré).
**Fichiers :** `.mcp.json`, `.gitignore`, `.mcp.json.example` (nouveau)
### T-002 — Supprimer `create_test_user.php` et vérifier la prod
**Pourquoi :** ce script de debug, commité à la racine, crée un compte `ROLE_ADMIN` avec `admin@admin.com` / `admin123` — un mot de passe devinable en quelques essais. S'il a déjà tourné en production, un compte admin faible existe peut-être en ce moment.
**À faire :**
1. Vérifier en prod qu'aucun profil `admin@admin.com` n'est actif :
```sql
SELECT id, email, is_active, roles FROM profiles WHERE email = 'admin@admin.com';
```
S'il existe : le désactiver ou changer son mot de passe immédiatement.
2. Supprimer le script :
```bash
git rm create_test_user.php
```
3. (Optionnel) Si le besoin « créer un admin de dev » existe encore, créer une commande Symfony `app:create-admin` qui prend le mot de passe en argument — ne jamais le hardcoder.
**Fichiers :** `create_test_user.php`
### T-003 — Changer le mot de passe PG prod et archiver les scripts `*-prod-*.php`
**Pourquoi :** 9 scripts dans `scripts/` contiennent le mot de passe de la base de production en clair (`fermerecette`). Ce sont des scripts de réparation one-shot qui ont déjà servi : ils n'ont plus de raison d'être dans le dépôt avec des secrets dedans.
**À faire :**
1. Changer le mot de passe de l'utilisateur PG concerné sur le serveur de prod.
2. Archiver les scripts (le dossier `_archives/` est déjà dans le `.gitignore`) :
```bash
mkdir -p _archives/scripts-prod
git rm scripts/check-prod-values.php scripts/check-prod-audit-dates.php \
scripts/check-prod-missing-piece-cfs.php scripts/check-prod-orphaned-detail.php \
scripts/fix-prod-all.php scripts/fix-prod-recreate-and-migrate.php \
scripts/migrate-orphaned-custom-fields.php scripts/restore-custom-field-values.php \
scripts/verify-prod-health.php
# (les copies locales peuvent aller dans _archives/scripts-prod si tu veux les garder)
```
3. Si l'un d'eux doit rester utilisable : remplacer les credentials en dur par `getenv('DATABASE_URL')`.
**Fichiers :** `scripts/*-prod-*.php`, `scripts/migrate-orphaned-custom-fields.php`, `scripts/restore-custom-field-values.php`
---
## P1 — Importants
### T-004 — Ajouter une CI qui lance tests et lint
**Pourquoi :** aujourd'hui, rien ne lance les tests automatiquement : la CI Gitea ne fait que tagger et builder l'image Docker, et le hook pre-commit est contourné avec `--no-verify` (trop lent). On peut donc livrer en prod avec des tests rouges sans aucune alerte.
**À faire :**
1. Créer `.gitea/workflows/ci.yml` :
```yaml
name: CI
on:
pull_request:
push:
branches: [develop]
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: root
POSTGRES_PASSWORD: root
POSTGRES_DB: inventory_test
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with: { php-version: '8.4', extensions: 'pdo_pgsql, intl' }
- run: composer install --no-interaction --prefer-dist
- run: vendor/bin/php-cs-fixer fix --dry-run --diff --allow-risky=yes
- run: php bin/console doctrine:schema:create --env=test
env: { DATABASE_URL: "postgresql://root:root@localhost:5432/inventory_test" }
- run: vendor/bin/phpunit
env: { DATABASE_URL: "postgresql://root:root@localhost:5432/inventory_test" }
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
working-directory: frontend
- run: npx eslint .
working-directory: frontend
- run: npx nuxi typecheck
working-directory: frontend
- run: npm run build
working-directory: frontend
```
> Adapter les détails (version PHP exacte, env de test) au premier run — l'important est que les 4 vérifications (cs-fixer, PHPUnit, ESLint, typecheck) tournent.
2. Dans Gitea, marquer ce workflow comme requis pour merger une PR vers `develop`.
**Fichiers :** `.gitea/workflows/ci.yml` (nouveau)
### T-005 — Définir une limite de taille d'upload à tous les niveaux
**Pourquoi :** aucune limite n'est choisie nulle part. Conséquence double : en prod, nginx applique son défaut de **1 Mo** (les gros PDF sont sans doute rejetés avec une erreur brute), et côté application rien n'empêcherait de remplir le disque si les limites infra étaient relevées. Décision à prendre : 50 Mo max (à ajuster au métier).
**À faire :**
1. Check applicatif dans `DocumentUploadProcessor::handleMultipartUpload()` (après la validation MIME, ligne ~79) :
```php
private const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 Mo
if ($file->getSize() > self::MAX_UPLOAD_BYTES) {
throw new BadRequestHttpException('Fichier trop volumineux (max 50 Mo).');
}
```
2. Même check dans `CommentController::create()` dans la boucle `foreach ($files as $file)` (ligne ~106) — renvoyer `$this->json(['message' => 'Fichier trop volumineux (max 50 Mo).'], 400)`.
3. `infra/dev/php.ini` — ajouter :
```ini
upload_max_filesize = 50M
post_max_size = 55M
```
Et vérifier que l'image prod (infra/prod/Dockerfile) reçoit la même config.
4. `infra/prod/nginx.conf` — dans le bloc `server` :
```nginx
client_max_body_size 55m;
```
(idem dans `nginx-proxy.conf` si le proxy frontal est aussi versionné ici).
5. Tester : upload d'un PDF de ~5 Mo en local, et vérifier le message d'erreur propre au-delà de 50 Mo.
**Fichiers :** `src/State/DocumentUploadProcessor.php`, `src/Controller/CommentController.php`, `infra/dev/php.ini`, `infra/prod/nginx.conf`, `infra/prod/Dockerfile`
### T-006 — Corriger le fallback d'URL API du frontend
**Pourquoi :** si la variable `NUXT_PUBLIC_API_BASE_URL` est vide en prod, tous les appels API partent vers `http://localhost:3000` — c'est-à-dire vers la machine de l'utilisateur. L'app casse silencieusement. Un fallback vide = « même origine », ce qui est le comportement correct.
**À faire :**
1. `frontend/app/composables/useApi.ts` ligne 18 :
```ts
// AVANT
const API_BASE_URL = (publicConfig.apiBaseUrl as string) || 'http://localhost:3000'
// APRÈS (chaîne vide = même origine ; useApi ajoute déjà /api)
const API_BASE_URL = (publicConfig.apiBaseUrl as string) || ''
```
2. `frontend/nuxt.config.ts` ligne ~49 : remplacer le fallback `'http://localhost/api'` par `''` (valeur SSR jamais utilisée, SSR off — autant ne pas mentir).
3. Vérifier en dev que tout fonctionne encore (la variable est définie en dev, donc rien ne doit changer), puis `npx nuxi typecheck`.
**Fichiers :** `frontend/app/composables/useApi.ts`, `frontend/nuxt.config.ts`
### T-007 — Purger la doc « submodule » (le frontend est dans le monorepo)
**Pourquoi :** le frontend a été rapatrié dans le repo principal, mais README, DEPLOY, RELEASE et le README frontend décrivent encore le clonage `--recurse-submodules` et le workflow de commit en deux temps. Un nouveau dev (ou toi en incident de prod) suit des étapes qui n'existent plus.
**À faire :**
1. `README.md` : lignes 49-50 → `git clone <url-du-repo>` ; section « workflow » lignes ~291-296 → décrire « un seul commit depuis la racine couvre backend + frontend ».
2. `DEPLOY.md` : supprimer les `git submodule update --init --recursive` (lignes 58, 62, 208).
3. `RELEASE.md` : supprimer les étapes submodule (lignes 49, 117).
4. `frontend/README.md` : remplacer la section « submodule » (lignes 150-154) par « ce dossier fait partie du monorepo Inventory ».
**Fichiers :** `README.md`, `DEPLOY.md`, `RELEASE.md`, `frontend/README.md`
### T-008 — Corriger l'utilisateur PG dans `DEPLOY.md`
**Pourquoi :** les commandes de DEPLOY.md utilisent `ferme_user` / `fermerecette` — copié-collé du projet Ferme. Le vrai utilisateur est `inventory_user`. En situation d'incident, suivre la doc ferait taper des commandes qui échouent ou visent la mauvaise base.
**À faire :**
1. Remplacer les 6 occurrences de `ferme_user` par `inventory_user`.
2. Remplacer le mot de passe en clair par un placeholder `<mot-de-passe-prod>` (le mot de passe n'a rien à faire dans la doc).
**Fichiers :** `DEPLOY.md`
### T-009 — Durcir le garde anti path-traversal de `DocumentStorageService`
**Pourquoi :** le contrôle `realpath()` (qui vérifie que le chemin final est bien dans le dossier de stockage) est sauté quand le fichier n'existe pas, car `realpath()` renvoie `false` dans ce cas. Le risque actuel est faible (le chemin vient de la base, pas de l'utilisateur), mais le contrôle se veut une protection — autant qu'il protège vraiment.
**À faire :** dans `getAbsolutePath()` (`src/Service/DocumentStorageService.php:28-42`) :
```php
// AVANT
$absolutePath = $this->storageDir.'/'.$relativePath;
$realPath = realpath($absolutePath);
if (false !== $realPath && !str_starts_with($realPath, realpath($this->storageDir))) {
throw new RuntimeException(sprintf('Path traversal detected: "%s"', $relativePath));
}
// APRÈS — valider sur le dossier parent, qui existe toujours pour un fichier servi
$absolutePath = $this->storageDir.'/'.$relativePath;
$realParent = realpath(dirname($absolutePath));
$realStorage = realpath($this->storageDir);
if (false === $realStorage || false === $realParent
|| !str_starts_with($realParent.'/', $realStorage.'/')) {
throw new RuntimeException(sprintf('Path traversal detected: "%s"', $relativePath));
}
```
Garder le check `str_contains($relativePath, '..')` existant en première ligne. Ajouter un test unitaire avec un chemin contenant `../` et un chemin absolu.
**Fichiers :** `src/Service/DocumentStorageService.php`, `tests/` (nouveau test)
---
## P2 — Consolidation
### T-010 — Hardening CSRF : header `X-Requested-With` obligatoire sur les écritures
**Pourquoi :** l'auth est par cookie, et la seule protection contre le CSRF (un site malveillant qui fait faire des requêtes à ton navigateur connecté) est l'attribut `SameSite=Lax` du cookie. Une deuxième barrière peu coûteuse : exiger un header custom, qu'un formulaire HTML cross-site ne peut pas envoyer.
**À faire :**
1. Côté front, dans `useApi.ts`, ajouter `'X-Requested-With': 'XMLHttpRequest'` aux headers de toutes les requêtes.
2. Côté back, créer un listener `kernel.request` qui renvoie 403 si la méthode n'est pas GET/HEAD/OPTIONS, que le chemin matche `^/api` (hors `/api/session/profile` pour le login) et que le header est absent.
3. Supprimer la clé morte `csrfToken` de `nuxt.config.ts` (elle laisse croire qu'une protection CSRF existe).
4. Adapter les tests API (`AbstractApiTestCase`) pour envoyer le header.
**Fichiers :** `frontend/app/composables/useApi.ts`, `src/EventListener/` (nouveau), `frontend/nuxt.config.ts`, `tests/AbstractApiTestCase.php`
### T-011 — Test fonctionnel : l'ID de session change au login
**Pourquoi :** la protection automatique contre la fixation de session est désactivée (`session_fixation_strategy: none`, choix documenté pour la SPA) et compensée par un `$session->migrate(true)` manuel au login. Si quelqu'un supprime ce `migrate` un jour, plus rien ne protège — un test doit le verrouiller.
**À faire :** écrire un test API qui : récupère le cookie de session avant login → se loggue → vérifie que l'ID de session a changé. Ajouter un commentaire dans `SessionProfileController::activateProfile()` pointant vers `security.yaml:5`.
**Fichiers :** `tests/Api/SessionProfileTest.php` (ou équivalent), `src/Controller/SessionProfileController.php`
### T-012 — MCP : remplacer le mot de passe par un token Bearer + HTTPS
**Pourquoi :** le mot de passe du profil circule en clair dans les headers de chaque requête MCP (et l'URL configurée est en `http://`). Les headers finissent dans les logs des proxys. Un token dédié révocable, transmis en `Authorization: Bearer`, est le pattern déjà utilisé par Lesstime.
**À faire :** ajouter un champ `mcpToken` (hashé) sur Profile ou une table `api_tokens`, générer via une commande console, adapter `McpHeaderAuthenticator` pour valider le Bearer (garder le rate-limiting), mettre à jour `.mcp.json.example`, et servir `/_mcp` en HTTPS uniquement.
**Fichiers :** `src/Mcp/Security/McpHeaderAuthenticator.php`, `src/Entity/Profile.php` ou nouvelle entité, migration, `.mcp.json.example`
### T-013 — Maintenance : faire respecter le flag côté backend
**Pourquoi :** le toggle admin écrit `var/maintenance`, mais seul le **frontend** (middleware) le vérifie. Quelqu'un qui appelle l'API directement (curl, MCP, script) passe à travers. Et le second mécanisme (`maintenance.on` lu par nginx, posé par `deploy.sh`) n'est documenté nulle part.
**À faire :**
1. Listener `kernel.request` backend : si `var/maintenance` existe et que l'utilisateur n'est pas admin → 503 JSON (sauf `/api/maintenance/check` et `/api/session/*`).
2. Documenter les deux niveaux (applicatif vs nginx/deploy) dans `DEPLOY.md`.
**Fichiers :** `src/EventListener/` (nouveau), `DEPLOY.md`
### T-014 — Résoudre l'incohérence provider/email nullable
**Pourquoi :** le user provider Symfony charge les profils par `email`, mais l'email est nullable (profils « kiosque »). Ça marche par chance (l'authenticator charge par ID), mais tout futur usage du provider standard cassera sur ces profils.
**À faire :** décision à prendre — option A : email obligatoire + email technique généré pour les kiosques ; option B : provider custom qui charge par id ou email. Documenter le choix dans `docs/BACKEND.md`.
**Fichiers :** `config/packages/security.yaml`, `src/Entity/Profile.php`, éventuellement `src/Security/`
### T-015 — Supprimer `@nuxtjs/tailwindcss`
**Pourquoi :** dépendance non utilisée (le projet utilise `@tailwindcss/vite`, Tailwind 4) qui installe en plus un Tailwind 3 parallèle — bloat et risque de conflit de résolution.
**À faire :**
```bash
cd frontend && npm uninstall @nuxtjs/tailwindcss && npm run build && npx nuxi typecheck
```
**Fichiers :** `frontend/package.json`, `frontend/package-lock.json`
### T-016 — Mettre à jour la doc de versioning (`VERSION` → `config/version.yaml`)
**Pourquoi :** `RELEASE.md` (lignes 17, 50, 80, 82) et l'arbre projet de `CLAUDE.md` référencent un fichier `VERSION` qui n'existe plus — la version vit dans `config/version.yaml`.
**À faire :** remplacer toutes les références ; vérifier au passage que la description du footer frontend (« lit VERSION au build ») correspond au mécanisme réel.
**Fichiers :** `RELEASE.md`, `CLAUDE.md`
### T-017 — Sortir les données client de la racine + gitignore défensif
**Pourquoi :** `customer.json`, `Company (1).json`, `Ensemble simple rotor.pdf` (données client réelles → enjeu RGPD) traînent à la racine, non protégés par le `.gitignore` : un `git add .` distrait les commiterait.
**À faire :**
1. Déplacer ces fichiers hors du dépôt (ex. `~/imports/inventory/`). Supprimer aussi `inventory_prod (2).sql.gz` et le `node_modules/` orphelin de la racine.
2. Ajouter au `.gitignore` racine :
```
/node_modules/
/*.json
/*.pdf
```
(les `.json` légitimes du projet sont dans des sous-dossiers ou explicitement trackés — `composer.json` etc. restent suivis car déjà trackés ; pour les nouveaux, `git add -f` reste possible).
**Fichiers :** `.gitignore`, racine du projet
### T-018 — Uniformiser la gestion d'erreur frontend (état d'erreur au lieu de `console.error`)
**Pourquoi :** en cas d'échec de chargement, le pattern actuel est `console.error(...)` puis la page s'affiche à moitié vide, sans message. L'utilisateur ne sait pas que quelque chose a raté ni comment réessayer.
**À faire :** règle : `useApi` est le seul à toaster ; les composables exposent un `error: Ref<string | null>` que la page affiche (bandeau avec bouton Réessayer). Commencer par les 3 pages principales : détail machine (`useMachineDetailData.ts:372,385`), détail composant, détail pièce. Étendre ensuite au reste.
**Fichiers :** `frontend/app/composables/useMachineDetailData.ts`, `useComponentEdit.ts`, `usePieceEdit.ts`, pages correspondantes
### T-019 — Cacher le résultat de `/maintenance/check` (TTL)
**Pourquoi :** chaque navigation d'un non-admin déclenche un appel API pour vérifier la maintenance — de la latence sur toutes les transitions de page pour un état qui ne change presque jamais.
**À faire :** dans `profile.global.ts`, stocker le résultat dans un `useState` avec timestamp et ne re-fetcher que si > 60 s.
**Fichiers :** `frontend/app/middleware/profile.global.ts`
### T-020 — Détracker `infra/dev/.env.docker.local` + fournir un `.example`
**Pourquoi :** le fichier est dans le `.gitignore` mais a été commité avant l'ajout de la règle — git continue donc de le suivre. Chaque dev qui le modifie crée du diff, et ses secrets (même de dev) sont versionnés.
**À faire :**
```bash
cp infra/dev/.env.docker.local infra/dev/.env.docker.local.example
# Dans le .example : remplacer les valeurs par des placeholders <CHANGE_ME>
# et supprimer les variables JWT_* (inutilisées, cf. T-023)
git rm --cached infra/dev/.env.docker.local
git add infra/dev/.env.docker.local.example
```
Mettre à jour le README (section installation) pour mentionner la copie du `.example`.
**Fichiers :** `infra/dev/.env.docker.local`, `infra/dev/.env.docker.local.example` (nouveau), `README.md`
---
## P3 — Nice to have
### T-021 — Décision métier : commentaires (et pièces jointes) en `ROLE_VIEWER` ?
**Pourquoi :** seule écriture accessible aux lecteurs. Si c'est voulu (« tout le monde commente »), le documenter dans `docs/BACKEND.md` ; sinon passer à `ROLE_GESTIONNAIRE` (`CommentController.php:33`).
**Fichiers :** `src/Controller/CommentController.php`, `docs/BACKEND.md`
### T-022 — Revoir les pagination max (2000/1000/500)
**Pourquoi :** `Constructeur` (2000), `ConstructeurCategorie` (1000), `Document` (500) — vérifier le besoin réel du front et redescendre, ou commenter pourquoi.
**Fichiers :** `src/Entity/Constructeur.php`, `src/Entity/ConstructeurCategorie.php`, `src/Entity/Document.php`
### T-023 — Nettoyer les variables JWT et les secrets `changeme`
**Pourquoi :** `JWT_SECRET_KEY`/`JWT_PUBLIC_KEY`/`JWT_PASSPHRASE` ne servent à rien (auth session). `APP_SECRET=changeme_…` mérite une vraie valeur aléatoire locale. (Fusionne naturellement avec T-020.)
**Fichiers :** `infra/dev/.env.docker.local`
### T-024 — Supprimer la config morte de `nuxt.config.ts`
**Pourquoi :** `csrfToken`, `requestTimeout`, `enableDebug`, `enableAnalytics`, `logLevel` ne sont consommés nulle part — de la config qui ment. (`csrfToken` est déjà traité par T-010.)
**Fichiers :** `frontend/nuxt.config.ts:56-59`
### T-025 — Nettoyer le Dockerfile dev (blocs Oracle/IMAP/MySQL commentés)
**Pourquoi :** restes d'un template générique sans rapport avec un projet PostgreSQL.
**Fichiers :** `infra/dev/Dockerfile:48-53,82-100`
### T-026 — Renommer les 3 utils `.js` en `.ts`
**Pourquoi :** `documentPreview.js`, `fileIcons.js`, `printTemplates/machineReport.js` sont importés depuis du TypeScript sans types. Tâche mécanique (bon candidat pour Codex).
**Fichiers :** `frontend/app/utils/documentPreview.js`, `frontend/app/utils/fileIcons.js`, `frontend/app/utils/printTemplates/machineReport.js`
### T-027 — Ajouter `nosniff` + `CSP: sandbox` à `download()`
**Pourquoi :** `serve()` envoie ces deux headers de protection, `download()` non — asymétrie gratuite.
**À faire :** copier les deux `headers->set(...)` de `serve()` dans `download()` (`DocumentServeController.php:110-116`).
**Fichiers :** `src/Controller/DocumentServeController.php`
### T-028 — Smoke test de l'image Docker avant push
**Pourquoi :** `build-docker.yml` pousse `latest` sans vérifier que l'image démarre.
**À faire :** entre build et push : `docker run --rm gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }} php bin/console about`.
**Fichiers :** `.gitea/workflows/build-docker.yml`
> **Hors tickets :** la dette d'architecture (smartMatch dupliqué, double flush, `pendingStructure`, God controller à 1121 lignes, `any` ×179…) a déjà son plan d'action chiffré dans `docs/REVIEW_ARCHITECTURE.md`. Recommandation forte : exécuter sa **Phase 1** (4 corrections effort S, sans impact d'interface) avant qu'elle ne prenne encore 3 mois — voir `REVIEW.md` §4.
---
## Résumé
| Priorité | Tickets | Estimation |
|----------|---------|------------|
| **P0** | T-001 à T-003 | ~2h (+ rotations de secrets) |
| **P1** | T-004 à T-009 | ~1,5 j |
| **P2** | T-010 à T-020 | ~2,5 j |
| **P3** | T-021 à T-028 | ~0,5 j |
| **Total** | 28 tickets | ~5 j |
> Commence par **T-001** — tant que les secrets ne sont pas révoqués, tout le reste est secondaire.
> Pour chaque ticket, fais un commit dédié avec le numéro dans le message (ex. `fix(T-001) : retirer .mcp.json du dépôt`).
+1 -1
View File
@@ -1,7 +1,7 @@
api_platform:
title: Inventory API
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
version: 1.9.6
version: 1.9.40
defaults:
stateless: false
cache_headers:
+5
View File
@@ -69,3 +69,8 @@ when@test:
autowire: true
autoconfigure: true
public: true
App\Service\SkeletonStructureService:
autowire: true
autoconfigure: true
public: true
+1 -1
View File
@@ -1,2 +1,2 @@
parameters:
app.version: '1.9.30'
app.version: '1.9.47'
+17 -12
View File
@@ -69,9 +69,25 @@
<span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}</span>
</div>
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
<div
v-if="visibleContextFieldTags.length"
class="flex flex-wrap items-center gap-2"
>
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
:class="contextFieldBadgeClass(field)"
>
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
<span class="text-sm font-bold">{{ field.value }}</span>
</span>
</div>
<!-- Row 2: Metadata tags -->
<div
v-if="componentConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)"
v-if="componentConstructeursDisplay.length || displayProductName"
class="flex flex-wrap items-center gap-1.5"
>
<span
@@ -85,17 +101,6 @@
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }}
</span>
<!-- Context field tags (consultation only) -->
<template v-if="!isEditMode">
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
:class="contextFieldBadgeClass(field)"
>
{{ field.name }} : {{ field.value }}
</span>
</template>
</div>
</div>
@@ -124,6 +124,7 @@ import IconLucideCheck from '~icons/lucide/check'
import IconLucideX from '~icons/lucide/x'
import {
type ConstructeurSummary,
constructeurPhones,
formatConstructeurContact,
resolveConstructeurs,
uniqueConstructeurIds,
@@ -193,7 +194,7 @@ const filteredOptions = computed(() => {
return options.value.filter((option) =>
(option.name ?? '').toLowerCase().includes(term)
|| (option.email && option.email.toLowerCase().includes(term))
|| (option.phone && option.phone.toLowerCase().includes(term))
|| constructeurPhones(option).some(t => t.numero.toLowerCase().includes(term))
)
})
@@ -293,14 +294,14 @@ const handleCreate = async () => {
}
creating.value = true
const payload: { name: string; email?: string; phone?: string } = {
const payload: { name: string; email?: string; telephones?: Array<{ numero: string }> } = {
name: trimmedName,
}
if (createForm.value.email) {
payload.email = createForm.value.email
}
if (createForm.value.phone) {
payload.phone = createForm.value.phone
if (createForm.value.phone && createForm.value.phone.trim()) {
payload.telephones = [{ numero: createForm.value.phone.trim() }]
}
const result = await createConstructeur(payload)
creating.value = false
+15 -6
View File
@@ -15,10 +15,10 @@
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
</button>
<NuxtLink :to="backDestination" class="btn btn-ghost btn-sm md:btn-md">
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
{{ backLabel }}
</NuxtLink>
</button>
</div>
</div>
</template>
@@ -29,6 +29,7 @@ import IconLucideEye from '~icons/lucide/eye'
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const route = useRoute()
const router = useRouter()
const props = defineProps<{
title: string
@@ -43,12 +44,20 @@ defineEmits<{
'toggle-edit': []
}>()
const backDestination = computed(() => {
// Retour : on revient à l'URL précédente pour préserver l'état de la liste
// (recherche, tri, pagination persistés en query params). Fallback sur le
// backLink si pas d'historique applicatif (accès direct, refresh, lien partagé).
const goBack = () => {
if (route.query.from === 'machine' && route.query.machineId) {
return `/machine/${route.query.machineId}`
router.push(`/machine/${route.query.machineId}`)
return
}
return props.backLink
})
if (window.history.state?.back) {
router.back()
return
}
router.push(props.backLink)
}
const backLabel = computed(() => {
if (route.query.from === 'machine') {
+17 -12
View File
@@ -71,9 +71,25 @@
<span v-if="pieceData.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ pieceData.prix }}</span>
</div>
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
<div
v-if="visibleContextFieldTags.length"
class="flex flex-wrap items-center gap-2"
>
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
:class="contextFieldBadgeClass(field)"
>
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
<span class="text-sm font-bold">{{ field.value }}</span>
</span>
</div>
<!-- Row 2: Metadata tags -->
<div
v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)"
v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName"
class="flex flex-wrap items-center gap-1.5"
>
<span v-if="piece.parentComponentName" class="text-[0.65rem] text-base-content/40 bg-base-300/20 px-1.5 py-0.5 rounded">
@@ -90,17 +106,6 @@
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }}
</span>
<!-- Context field tags (consultation only) -->
<template v-if="!isEditMode">
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
:class="contextFieldBadgeClass(field)"
>
{{ field.name }} : {{ field.value }}
</span>
</template>
</div>
</div>
@@ -388,7 +388,11 @@ const handleGlobalClick = (event) => {
onMounted(() => {
window.addEventListener('click', handleGlobalClick)
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
if (props.creatable) {
searchTerm.value = String(props.modelValue ?? '')
} else {
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
}
})
onBeforeUnmount(() => {
@@ -0,0 +1,153 @@
<template>
<div class="constructeur-categorie-select space-y-2">
<div class="flex flex-wrap gap-2 min-h-[1.75rem]">
<span v-if="!selected.length" class="text-sm text-base-content/50">
Aucune catégorie
</span>
<span
v-for="cat in selected"
:key="cat.id || cat.name"
class="badge badge-outline badge-lg gap-1"
>
<span>{{ cat.name }}</span>
<button
v-if="!disabled"
type="button"
class="btn btn-ghost btn-xs p-0 h-auto min-h-0"
aria-label="Retirer la catégorie"
@click="removeCategory(cat)"
>
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
</span>
</div>
<div v-if="!disabled" class="relative">
<input
v-model="searchTerm"
type="text"
class="input input-bordered input-sm md:input-md w-full"
:placeholder="placeholder"
@focus="open = true; ensureLoaded()"
@keydown.escape="open = false"
>
<div
v-if="open && (matches.length || canCreate)"
class="absolute z-30 mt-1 w-full max-h-56 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
>
<button
v-for="cat in matches"
:key="cat.id"
type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-sm"
@click="addCategory(cat)"
>
{{ cat.name }}
</button>
<button
v-if="canCreate"
type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-sm text-primary"
@click="createAndAdd"
>
+ Créer « {{ searchTerm.trim() }} »
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import type { PropType } from 'vue'
import { useConstructeurCategories, type ConstructeurCategorie } from '~/composables/useConstructeurCategories'
import IconLucideX from '~icons/lucide/x'
const props = defineProps({
modelValue: {
type: Array as PropType<ConstructeurCategorie[]>,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: 'Rechercher ou créer une catégorie…',
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: ConstructeurCategorie[]): void
}>()
const { categories, loadCategories, createCategory } = useConstructeurCategories()
const searchTerm = ref('')
const open = ref(false)
const loadedOnce = ref(false)
const selected = computed<ConstructeurCategorie[]>(() => props.modelValue || [])
const selectedKeys = computed(() => new Set(selected.value.map(c => (c.name || '').toLowerCase())))
const matches = computed<ConstructeurCategorie[]>(() => {
const term = searchTerm.value.trim().toLowerCase()
return categories.value
.filter(c => !selectedKeys.value.has((c.name || '').toLowerCase()))
.filter(c => !term || (c.name || '').toLowerCase().includes(term))
.slice(0, 50)
})
const canCreate = computed(() => {
const term = searchTerm.value.trim()
if (!term) {
return false
}
const lower = term.toLowerCase()
return !categories.value.some(c => (c.name || '').toLowerCase() === lower)
&& !selectedKeys.value.has(lower)
})
const ensureLoaded = async () => {
if (loadedOnce.value) {
return
}
loadedOnce.value = true
await loadCategories()
}
const emitSelection = (value: ConstructeurCategorie[]) => {
emit('update:modelValue', value)
}
const addCategory = (cat: ConstructeurCategorie) => {
if (selectedKeys.value.has((cat.name || '').toLowerCase())) {
return
}
emitSelection([...selected.value, cat])
searchTerm.value = ''
}
const removeCategory = (cat: ConstructeurCategorie) => {
emitSelection(selected.value.filter(c => c !== cat && c.id !== cat.id))
}
const createAndAdd = async () => {
const created = await createCategory(searchTerm.value)
if (created) {
addCategory(created)
}
}
const onDocumentClick = (event: Event) => {
const target = event.target as HTMLElement | null
if (target && !target.closest('.constructeur-categorie-select')) {
open.value = false
}
}
onMounted(() => document.addEventListener('click', onDocumentClick))
onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
</script>
@@ -5,6 +5,19 @@
Ajouter une nouvelle machine
</h3>
<form @submit.prevent="handleSubmit">
<div v-if="errorMessage" class="alert alert-error mb-4" role="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
</svg>
<span>{{ errorMessage }}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-control">
<label class="label">
@@ -78,6 +91,7 @@ const props = defineProps<{
sites: Array<{ id: string, name: string }>
disabled: boolean
preselectedSiteId?: string
errorMessage?: string | null
}>()
const emit = defineEmits<{
@@ -4,7 +4,7 @@
<ul>
<!-- First crumb (always visible) -->
<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 }}
</NuxtLink>
</li>
@@ -18,7 +18,7 @@
:key="i"
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 }}
</NuxtLink>
</li>
@@ -32,15 +32,40 @@
</template>
<script setup lang="ts">
import type { RouteLocationRaw } from 'vue-router'
import { useListQueryMemory } from '~/composables/useListQueryMemory'
interface Crumb {
label: string
path: string
to: RouteLocationRaw
}
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 result: Crumb[] = [{ label: 'Accueil', path: '/' }]
const result: Crumb[] = [{ label: 'Accueil', to: '/' }]
const path = route.path
// Home page — no breadcrumb
@@ -48,88 +73,88 @@ const crumbs = computed<Crumb[]>(() => {
// Machine context from query param (when navigating from a machine detail page)
if (route.query.from === 'machine' && route.query.machineId) {
result.push({ label: 'Parc machines', path: '/machines' })
result.push({ label: 'Machine', path: `/machine/${route.query.machineId}` })
result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Machine', to: `/machine/${route.query.machineId}` })
}
// 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) {
result.push({ label: 'Parc machines', path: '/machines' })
result.push({ label: 'Machine', path })
result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Machine', to: path })
}
// Catalogs
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')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
} 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)
else if (path.startsWith('/component/') && !route.query.from) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
result.push({ label: 'Composant', path })
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Composant', to: path })
} else if (path.startsWith('/piece/') && !route.query.from) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
result.push({ label: 'Pièce', path })
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Pièce', to: path })
} else if (path.startsWith('/product/') && !route.query.from) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
result.push({ label: 'Produit', path })
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Produit', to: path })
}
// Entity detail pages WITH machine context — add entity as last crumb
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') {
result.push({ label: 'Pièce', path })
result.push({ label: 'Pièce', to: path })
} else if (path.startsWith('/product/') && route.query.from === 'machine') {
result.push({ label: 'Produit', path })
result.push({ label: 'Produit', to: path })
}
// Admin pages
else if (path.startsWith('/sites')) {
result.push({ label: 'Sites', path: '/sites' })
result.push({ label: 'Sites', to: '/sites' })
} else if (path.startsWith('/constructeurs')) {
result.push({ label: 'Fournisseurs', path: '/constructeurs' })
result.push({ label: 'Fournisseurs', to: '/constructeurs' })
} 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')) {
result.push({ label: 'Administration', path: '/admin' })
result.push({ label: 'Administration', to: '/admin' })
} else if (path.startsWith('/documents')) {
result.push({ label: 'Documents', path: '/documents' })
result.push({ label: 'Documents', to: '/documents' })
} else if (path.startsWith('/comments')) {
result.push({ label: 'Commentaires', path: '/comments' })
result.push({ label: 'Commentaires', to: '/comments' })
}
// Category pages
else if (path.startsWith('/component-category')) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
result.push({ label: 'Catégorie', path })
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Catégorie', to: path })
} else if (path.startsWith('/piece-category')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
result.push({ label: 'Catégorie', path })
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Catégorie', to: path })
} else if (path.startsWith('/product-category')) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
result.push({ label: 'Catégorie', path })
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Catégorie', to: path })
}
// Create pages
else if (path.startsWith('/pieces/create')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
result.push({ label: 'Nouvelle pièce', path })
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
result.push({ label: 'Nouvelle pièce', to: path })
} else if (path.startsWith('/component/create')) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
result.push({ label: 'Nouveau composant', path })
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
result.push({ label: 'Nouveau composant', to: path })
} else if (path.startsWith('/product/create')) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
result.push({ label: 'Nouveau produit', path })
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
result.push({ label: 'Nouveau produit', to: path })
} else if (path === '/machines/new') {
result.push({ label: 'Parc machines', path: '/machines' })
result.push({ label: 'Nouvelle machine', path })
result.push({ label: 'Parc machines', to: listTo('/machines') })
result.push({ label: 'Nouvelle machine', to: path })
}
return result
@@ -36,10 +36,10 @@
>
<IconLucidePrinter class="w-4 h-4" aria-hidden="true" />
</button>
<NuxtLink to="/machines" class="btn btn-ghost btn-sm md:btn-md">
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
<IconLucideArrowLeft class="w-4 h-4 mr-1" aria-hidden="true" />
Parc machines
</NuxtLink>
</button>
</div>
</div>
</div>
@@ -52,6 +52,18 @@ import IconLucidePrinter from '~icons/lucide/printer'
import IconLucideArrowLeft from '~icons/lucide/arrow-left'
const { canEdit } = usePermissions()
const router = useRouter()
// Retour : revient à l'URL précédente pour préserver la recherche/filtres du
// parc machines (persistés en query params). Fallback vers /machines si pas
// d'historique applicatif (accès direct, refresh, lien partagé).
const goBack = () => {
if (window.history.state?.back) {
router.back()
return
}
router.push('/machines')
}
const props = defineProps<{
title: string
@@ -281,7 +281,10 @@ const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}
limit.value = response.limit
}
catch (error: unknown) {
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
// Requête annulée volontairement (nouvelle recherche / démontage) : pas une
// vraie erreur. On teste le signal car ofetch encapsule l'AbortError dans
// une FetchError, donc error.name n'est pas fiable.
if (controller.signal.aborted) return
showError(extractErrorMessage(error))
}
finally {
@@ -204,7 +204,7 @@ const formulaBuilderCustomFields = computed(() => {
const extractFormulaFields = (formula: string | null | undefined): string[] => {
if (!formula) return []
const matches = [...formula.matchAll(/\{(\w+)\}/g)]
const matches = [...formula.matchAll(/\{([^}]+)\}/gu)]
return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))]
}
@@ -91,7 +91,7 @@ const preview = computed(() => {
fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR')
}
}
return props.modelValue.replace(/\{(\w+)\}/g, (_, name) => fieldMap.get(name) ?? '???')
return props.modelValue.replace(/\{([^}]+)\}/gu, (_, name) => fieldMap.get(name) ?? '???')
})
const insertField = (fieldName: string) => {
@@ -0,0 +1,63 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface ConstructeurCategorie {
'@id'?: string
id: string
name: string
}
const categories = ref<ConstructeurCategorie[]>([])
const loading = ref(false)
const loaded = ref(false)
const sortByName = (items: ConstructeurCategorie[]): ConstructeurCategorie[] =>
[...items].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
export function useConstructeurCategories() {
const { get, post } = useApi()
const { showError } = useToast()
const loadCategories = async (force = false): Promise<ConstructeurCategorie[]> => {
if (loaded.value && !force) {
return categories.value
}
loading.value = true
try {
const result = await get('/constructeur_categories?itemsPerPage=1000')
if (result.success) {
categories.value = sortByName(extractCollection<ConstructeurCategorie>(result.data))
loaded.value = true
}
return categories.value
}
finally {
loading.value = false
}
}
const createCategory = async (name: string): Promise<ConstructeurCategorie | null> => {
const trimmed = name.trim()
if (!trimmed) {
return null
}
const existing = categories.value.find(c => c.name.toLowerCase() === trimmed.toLowerCase())
if (existing) {
return existing
}
const result = await post('/constructeur_categories', { name: trimmed })
if (result.success && result.data && !Array.isArray(result.data)) {
const created = result.data as ConstructeurCategorie
categories.value = sortByName([...categories.value, created])
return created
}
if (result.error) {
showError(result.error)
}
return null
}
return { categories, loading, loadCategories, createCategory }
}
+75 -6
View File
@@ -1,13 +1,30 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface ConstructeurTelephone {
'@id'?: string
id?: string
numero: string
label?: string | null
}
export interface ConstructeurCategorieRef {
'@id'?: string
id: string
name: string
}
export interface Constructeur {
'@id'?: string
id: string
name: string
email?: string | null
phone?: string | null
telephones?: ConstructeurTelephone[]
categories?: ConstructeurCategorieRef[]
createdAt?: string
updatedAt?: string
}
interface ConstructeurResult {
@@ -16,6 +33,24 @@ interface ConstructeurResult {
error?: string
}
export interface ConstructeurPageOptions {
page?: number
itemsPerPage?: number
search?: string
categoryId?: string
orderField?: 'name' | 'email' | 'createdAt'
orderDirection?: 'asc' | 'desc'
}
export interface ConstructeurPageResult {
success: boolean
items: Constructeur[]
totalItems: number
totalPages: number
currentPage: number
error?: string
}
const constructeurs = ref<Constructeur[]>([])
const loading = ref(false)
const loaded = ref(false)
@@ -66,8 +101,10 @@ export function useConstructeurs() {
}
loading.value = true
try {
const query = search ? `?search=${encodeURIComponent(search)}` : ''
const result = await get(`/constructeurs${query}`)
const params = new URLSearchParams()
params.set('itemsPerPage', '2000')
if (search) params.set('search', search)
const result = await get(`/constructeurs?${params.toString()}`)
if (result.success) {
const items = extractCollection(result.data)
constructeurs.value = uniqueConstructeurs(items)
@@ -87,7 +124,38 @@ export function useConstructeurs() {
return loadConstructeurs(search)
}
const createConstructeur = async (data: Partial<Constructeur>): Promise<ConstructeurResult> => {
const fetchConstructeursPage = async (opts: ConstructeurPageOptions = {}): Promise<ConstructeurPageResult> => {
const page = Math.max(1, opts.page ?? 1)
const itemsPerPage = Math.max(1, opts.itemsPerPage ?? 30)
loading.value = true
try {
const params = new URLSearchParams()
params.set('page', String(page))
params.set('itemsPerPage', String(itemsPerPage))
if (opts.search && opts.search.trim()) params.set('search', opts.search.trim())
if (opts.categoryId) params.set('categories.id', opts.categoryId)
if (opts.orderField) {
params.set(`order[${opts.orderField}]`, opts.orderDirection ?? 'asc')
}
const result = await get(`/constructeurs?${params.toString()}`)
if (!result.success) {
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: result.error }
}
const items = extractCollection<Constructeur>(result.data)
const totalItems = extractTotal(result.data, items.length)
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
upsertConstructeurs(items)
return { success: true, items, totalItems, totalPages, currentPage: page }
} catch (error) {
const err = error as Error
console.error('Erreur lors du chargement de la page fournisseurs:', error)
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: err.message }
} finally {
loading.value = false
}
}
const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => {
loading.value = true
try {
const result = await post('/constructeurs', data)
@@ -161,7 +229,7 @@ export function useConstructeurs() {
.filter((item): item is Constructeur => item !== null)
}
const updateConstructeur = async (id: string, data: Partial<Constructeur>): Promise<ConstructeurResult> => {
const updateConstructeur = async (id: string, data: Record<string, unknown>): Promise<ConstructeurResult> => {
loading.value = true
try {
const result = await patch(`/constructeurs/${id}`, data)
@@ -210,6 +278,7 @@ export function useConstructeurs() {
loading,
loadConstructeurs,
searchConstructeurs,
fetchConstructeursPage,
createConstructeur,
updateConstructeur,
deleteConstructeur,
@@ -56,7 +56,9 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
// CRUD operations
const refreshDocuments = async () => {
const e = entity()
if (!e?.id || e._structurePiece) return
// Pending / category-only nodes carry the link id (not a real entity id) and
// have no backing piece/composant — never request documents for them.
if (!e?.id || e._structurePiece || e.pendingEntity) return
loadingDocuments.value = true
try {
const result: any = await loadDocumentsFn(e.id, { updateStore: false })
@@ -70,7 +72,8 @@ export function useEntityDocuments(deps: EntityDocumentsDeps) {
}
const ensureDocumentsLoaded = async () => {
if (documentsLoaded.value || !entity()?.id) return
const e = entity()
if (documentsLoaded.value || !e?.id || e.pendingEntity) return
await refreshDocuments()
}
@@ -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 { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
export function useMachineCreatePage() {
@@ -18,7 +17,6 @@ export function useMachineCreatePage() {
const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
const { sites, loadSites } = useSites()
const toast = useToast()
// ---------------------------------------------------------------------------
// Local state
@@ -27,6 +25,9 @@ export function useMachineCreatePage() {
const submitting = ref(false)
const loading = ref(true)
/** Persistent error shown inline in the form (e.g. duplicate name on the same site). */
const createError = ref<string | null>(null)
const newMachine = reactive({
name: '',
siteId: '',
@@ -41,8 +42,10 @@ export function useMachineCreatePage() {
const finalizeMachineCreation = async () => {
if (submitting.value) return
createError.value = null
if (!newMachine.name?.trim()) {
toast.showError('Merci de renseigner un nom pour la machine')
createError.value = 'Merci de renseigner un nom pour la machine.'
return
}
@@ -80,10 +83,10 @@ export function useMachineCreatePage() {
await navigateTo('/machines')
}
} else if (result.error) {
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
createError.value = humanizeError(result.error)
}
} catch (error: any) {
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
createError.value = humanizeError(error.message)
} finally {
submitting.value = false
}
@@ -116,6 +119,7 @@ export function useMachineCreatePage() {
machines,
submitting,
loading,
createError,
// Actions
finalizeMachineCreation,
+17 -2
View File
@@ -167,7 +167,7 @@ import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { buildDeleteMessageWithUsage, type UsageInfo } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
@@ -249,10 +249,25 @@ const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
const { confirm } = useConfirm()
const api = useApi()
const handleDeletePiece = async (piece: Record<string, any>) => {
const pieceName = piece?.name || 'cette pièce'
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
let usage: UsageInfo = {}
try {
const result = await api.get(`/pieces/${piece.id}/used-in`)
if (result.success && result.data) {
usage = {
machines: result.data.machines ?? [],
composants: result.data.composants ?? [],
}
}
} catch (error) {
console.warn('Impossible de récupérer les usages de la pièce avant suppression :', error)
}
const message = buildDeleteMessageWithUsage(pieceName, 'Cette pièce', usage)
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
if (!confirmed) return
await deletePiece(piece.id)
@@ -159,6 +159,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadComponentTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de composant mise à jour avec succès.')
}
} catch (error) {
@@ -183,6 +184,7 @@ const handleSyncConfirm = async () => {
confirmTypeChanges: !!hasModifications,
})
await loadComponentTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de composant mise à jour avec succès.')
} catch (error) {
showError(normalizeError(error))
+238 -56
View File
@@ -6,7 +6,7 @@
Fournisseurs
</h1>
<p class="text-sm text-gray-500">
Gérez les fournisseurs et leurs coordonnées.
Gérez les fournisseurs, leurs coordonnées et leurs catégories.
</p>
</div>
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
@@ -19,29 +19,69 @@
<div class="card-body space-y-4">
<DataTable
:columns="columns"
:rows="filteredConstructeurs"
:rows="pageItems"
:loading="loading"
:sort="currentSort"
:show-counter="false"
:pagination="paginationState"
:show-counter="true"
:show-per-page="true"
empty-message="Aucun fournisseur trouvé."
no-results-message="Aucun fournisseur trouvé."
@sort="handleSort"
@update:current-page="onPageChange"
@update:per-page="onPerPageChange"
>
<template #toolbar>
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="searchTerm"
type="search"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom, email ou téléphone"
@input="debouncedSearch"
/>
</label>
<div class="flex flex-col sm:flex-row gap-3 w-full">
<label class="w-full sm:w-72">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="searchTerm"
type="search"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom, email ou téléphone"
@input="debouncedSearch"
>
</label>
<label class="w-full sm:w-64">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Catégorie</span>
<select
v-model="selectedCategoryId"
class="select select-bordered select-sm w-full mt-1"
>
<option value="">
Toutes les catégories
</option>
<option v-for="cat in allCategories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</option>
</select>
</label>
</div>
</template>
<template #cell-phone="{ row }">
{{ formatPhoneDisplay(row.phone) }}
<template #cell-telephones="{ row }">
<div v-if="rowPhones(row).length" class="flex flex-col gap-0.5">
<span v-for="(tel, idx) in rowPhones(row)" :key="idx" class="whitespace-nowrap text-sm">
{{ formatPhoneDisplay(tel.numero) }}
<span v-if="tel.label" class="text-xs text-base-content/50">({{ tel.label }})</span>
</span>
</div>
<span v-else class="text-base-content/30"></span>
</template>
<template #cell-categories="{ row }">
<div v-if="row.categories && row.categories.length" class="flex flex-wrap gap-1">
<span
v-for="cat in row.categories"
:key="cat.id"
class="badge badge-ghost badge-sm cursor-pointer hover:badge-primary transition-colors"
@click="selectedCategoryId = cat.id"
>
{{ cat.name }}
</span>
</div>
<span v-else class="text-base-content/30"></span>
</template>
<template #cell-createdAt="{ row }">
@@ -96,7 +136,7 @@
</div>
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
<div class="modal-box">
<div class="modal-box max-w-2xl">
<h3 class="font-bold text-lg mb-4">
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
</h3>
@@ -105,10 +145,53 @@
<label class="label"><span class="label-text">Nom</span></label>
<input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
<FieldPhone v-model="form.phone" label="Téléphone" :disabled="!canEdit" />
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
<div class="form-control">
<div class="flex items-center justify-between mb-1">
<span class="label-text">Téléphones</span>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="addTelephoneRow"
>
<IconLucidePlus class="w-3 h-3 mr-1" aria-hidden="true" />
Ajouter
</button>
</div>
<p v-if="!form.telephones.length" class="text-sm text-base-content/50">
Aucun téléphone.
</p>
<div v-for="(tel, idx) in form.telephones" :key="idx" class="flex items-end gap-2 mb-2">
<div class="flex-1">
<FieldPhone v-model="tel.numero" label="" :disabled="!canEdit" placeholder="Ex: 05 49 00 00 00" />
</div>
<input
v-model="tel.label"
type="text"
class="input input-bordered input-sm md:input-md w-40"
placeholder="Libellé (optionnel)"
:disabled="!canEdit"
>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-sm text-error"
aria-label="Supprimer ce téléphone"
@click="removeTelephoneRow(idx)"
>
<IconLucideX class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Catégories</span></label>
<ConstructeurCategorieSelect v-model="form.categories" :disabled="!canEdit" />
</div>
<div class="modal-action">
<button type="button" class="btn" @click="closeModal">
Annuler
@@ -125,26 +208,47 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue'
import ConstructeurCategorieSelect from '~/components/form/ConstructeurCategorieSelect.vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurCategories, type ConstructeurCategorie } from '~/composables/useConstructeurCategories'
import { useToast } from '~/composables/useToast'
import { usePersistedValue } from '~/composables/usePersistedValue'
import { constructeurPhones } from '~/shared/constructeurUtils'
import { formatPhone } from '~/utils/formatters/phone'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideX from '~icons/lucide/x'
interface TelephoneFormRow { '@id'?: string, numero: string, label: string }
interface ConstructeurFormState {
name: string
email: string
telephones: TelephoneFormRow[]
categories: ConstructeurCategorie[]
}
const api = useApi()
const { canEdit } = usePermissions()
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
const { constructeurs, loading, createConstructeur, updateConstructeur, deleteConstructeur, fetchConstructeursPage } = useConstructeurs()
const { categories: allCategories, loadCategories } = useConstructeurCategories()
const { showError } = useToast()
const pageItems = ref<typeof constructeurs.value>([])
const totalItems = ref(0)
const totalPages = ref(0)
const currentPage = ref(1)
const perPage = ref(30)
const perPageOptions = [15, 30, 50, 100]
const columns = [
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'phone', label: 'Téléphone', sortable: true },
{ key: 'telephones', label: 'Téléphones' },
{ key: 'categories', label: 'Catégories' },
{ key: 'createdAt', label: 'Date de création', sortable: true },
{ key: 'composantCount', label: 'Composants', align: 'center' },
{ key: 'pieceCount', label: 'Pièces', align: 'center' },
@@ -153,9 +257,10 @@ const columns = [
]
const searchTerm = ref('')
const selectedCategoryId = ref('')
const sortKey = usePersistedValue('constructeurs-sort', 'name')
const sortDir = ref('asc')
const stats = ref({})
const stats = ref<Record<string, { composantCount?: number, pieceCount?: number, machineCount?: number }>>({})
const currentSort = computed(() => ({
field: sortKey.value,
@@ -167,40 +272,80 @@ const handleSort = (sort) => {
sortDir.value = sort.direction
}
const paginationState = computed(() => ({
currentPage: currentPage.value,
totalPages: totalPages.value,
totalItems: totalItems.value,
pageItems: pageItems.value.length,
perPage: perPage.value,
perPageOptions,
}))
const SORTABLE_FIELDS = new Set(['name', 'email', 'createdAt'])
const loadPage = async () => {
const orderField = SORTABLE_FIELDS.has(sortKey.value)
? (sortKey.value as 'name' | 'email' | 'createdAt')
: 'name'
const result = await fetchConstructeursPage({
page: currentPage.value,
itemsPerPage: perPage.value,
search: searchTerm.value,
categoryId: selectedCategoryId.value || undefined,
orderField,
orderDirection: sortDir.value === 'desc' ? 'desc' : 'asc',
})
if (!result.success) {
if (result.error) showError(result.error)
pageItems.value = []
totalItems.value = 0
totalPages.value = 0
return
}
pageItems.value = result.items
totalItems.value = result.totalItems
totalPages.value = result.totalPages
if (currentPage.value > result.totalPages && result.totalPages > 0) {
currentPage.value = result.totalPages
}
}
const modalOpen = ref(false)
const saving = ref(false)
const editingConstructeur = ref(null)
const form = ref({ name: '', email: '', phone: '' })
const editingConstructeur = ref<Record<string, any> | null>(null)
const form = ref<ConstructeurFormState>({ name: '', email: '', telephones: [], categories: [] })
const filteredConstructeurs = computed(() => {
const key = sortKey.value
const dir = sortDir.value === 'desc' ? -1 : 1
const sorted = [...constructeurs.value].sort((a, b) => {
if (key === 'createdAt') {
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
}
return dir * (a[key] || '').localeCompare(b[key] || '')
})
if (!searchTerm.value) { return sorted }
const term = searchTerm.value.toLowerCase()
return sorted.filter(item =>
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term)),
)
const rowPhones = constructeurPhones
const debouncedSearch = debounce(() => {
currentPage.value = 1
loadPage()
}, 300)
watch(selectedCategoryId, () => {
currentPage.value = 1
loadPage()
})
const debouncedSearch = debounce(async () => {
await searchConstructeurs(searchTerm.value)
}, 300)
watch([sortKey, sortDir], () => {
currentPage.value = 1
loadPage()
})
const onPageChange = (page: number) => {
currentPage.value = page
loadPage()
}
const onPerPageChange = (value: number) => {
perPage.value = value
currentPage.value = 1
loadPage()
}
const formatDate = formatFrenchDate
const formatPhoneDisplay = (value) => {
const formatted = formatPhone(value)
if (formatted) {
return formatted
}
return value || '—'
}
const formatPhoneDisplay = value => formatPhone(value) || value || '—'
function debounce(fn, delay) {
let timeout
@@ -211,7 +356,7 @@ function debounce(fn, delay) {
}
const resetForm = () => {
form.value = { name: '', email: '', phone: '' }
form.value = { name: '', email: '', telephones: [], categories: [] }
editingConstructeur.value = null
}
@@ -225,7 +370,12 @@ const openEditModal = (constructeur) => {
form.value = {
name: constructeur.name,
email: constructeur.email || '',
phone: constructeur.phone || '',
telephones: (constructeur.telephones || []).map(t => ({
'@id': t['@id'],
numero: t.numero || '',
label: t.label || '',
})),
categories: (constructeur.categories || []).map(c => ({ ...c })),
}
modalOpen.value = true
}
@@ -235,8 +385,20 @@ const closeModal = () => {
resetForm()
}
const addTelephoneRow = () => {
form.value.telephones.push({ numero: '', label: '' })
}
const removeTelephoneRow = (idx) => {
form.value.telephones.splice(idx, 1)
}
const saveConstructeur = async () => {
const trimmedName = form.value.name.trim()
if (!trimmedName) {
showError('Le nom est obligatoire.')
return
}
const duplicate = constructeurs.value.find(
c => c.name.toLowerCase() === trimmedName.toLowerCase()
&& c.id !== editingConstructeur.value?.id,
@@ -247,9 +409,24 @@ const saveConstructeur = async () => {
}
saving.value = true
const payload = { ...form.value, name: trimmedName }
if (!payload.email) { delete payload.email }
if (!payload.phone) { delete payload.phone }
const payload = {
name: trimmedName,
email: form.value.email?.trim() || null,
telephones: form.value.telephones
.filter(t => t.numero && t.numero.trim())
.map((t) => {
const entry: { numero: string, label: string | null, '@id'?: string } = {
numero: t.numero.trim(),
label: t.label?.trim() || null,
}
if (t['@id']) { entry['@id'] = t['@id'] }
return entry
}),
categories: form.value.categories
.map(c => c['@id'] || (c.id ? `/api/constructeur_categories/${c.id}` : null))
.filter((iri): iri is string => Boolean(iri)),
}
let result
if (editingConstructeur.value) {
result = await updateConstructeur(editingConstructeur.value.id, payload)
@@ -260,7 +437,7 @@ const saveConstructeur = async () => {
saving.value = false
if (result.success) {
closeModal()
await searchConstructeurs(searchTerm.value)
await loadPage()
}
}
@@ -271,6 +448,10 @@ const confirmDelete = async (constructeur) => {
const result = await deleteConstructeur(constructeur.id)
if (!result.success && result.error) {
showError(result.error)
return
}
if (result.success) {
await loadPage()
}
}
@@ -282,7 +463,8 @@ const loadStats = async () => {
}
onMounted(() => {
loadConstructeurs()
loadPage()
loadCategories()
loadStats()
})
</script>
+65 -7
View File
@@ -58,7 +58,26 @@
</option>
</select>
</div>
<div class="form-control">
<div class="form-control md:w-52">
<label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Trier par</span>
</label>
<select v-model="sortOrder" class="select select-bordered w-full">
<option value="name-asc">
Nom (A → Z)
</option>
<option value="name-desc">
Nom (Z → A)
</option>
<option value="date-desc">
Plus récentes
</option>
<option value="date-asc">
Plus anciennes
</option>
</select>
</div>
<div class="form-control md:w-80">
<label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span>
</label>
@@ -66,13 +85,13 @@
<input
v-model="dateFrom"
type="date"
class="input input-bordered input-sm"
class="input input-bordered w-full"
>
<span class="text-xs text-base-content/50">à</span>
<input
v-model="dateTo"
type="date"
class="input input-bordered input-sm"
class="input input-bordered w-full"
>
</div>
</div>
@@ -97,7 +116,7 @@
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
Ajouter un site
</button>
<button class="btn btn-ghost btn-sm" @click="showAddMachineModal = true">
<button class="btn btn-ghost btn-sm" @click="openAddMachineModal">
Ajouter une machine
</button>
</div>
@@ -263,7 +282,8 @@
:sites="sites"
:disabled="!canEdit"
:preselected-site-id="preselectedSiteId"
@close="showAddMachineModal = false"
:error-message="addMachineError"
@close="closeAddMachineModal"
@create="handleCreateMachine"
/>
</main>
@@ -293,8 +313,10 @@ const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
// Data
const showAddSiteModal = ref(false)
const showAddMachineModal = ref(false)
const addMachineError = ref(null)
const searchTerm = ref('')
const selectedSiteFilter = ref('')
const sortOrder = ref('name-asc')
const dateFrom = ref('')
const dateTo = ref('')
const collapsedSites = ref([])
@@ -318,10 +340,33 @@ const machinesBySiteId = computed(() => {
return map
})
const sortMachines = (machineList) => {
const list = [...machineList]
switch (sortOrder.value) {
case 'name-desc':
return list.sort((a, b) =>
(b.name || '').localeCompare(a.name || '', 'fr', { sensitivity: 'base', numeric: true })
)
case 'date-desc':
return list.sort((a, b) =>
new Date(b.createdAt || 0) - new Date(a.createdAt || 0)
)
case 'date-asc':
return list.sort((a, b) =>
new Date(a.createdAt || 0) - new Date(b.createdAt || 0)
)
case 'name-asc':
default:
return list.sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr', { sensitivity: 'base', numeric: true })
)
}
}
const sitesWithMachines = computed(() => {
return sites.value.map((site) => ({
...site,
machines: machinesBySiteId.value.get(site.id) || []
machines: sortMachines(machinesBySiteId.value.get(site.id) || [])
}))
})
@@ -406,11 +451,14 @@ const handleCreateSite = async (data) => {
}
const handleCreateMachine = async (data) => {
addMachineError.value = null
const result = await createMachine(data)
if (result.success) {
showAddMachineModal.value = false
await loadMachines()
} else if (result.error) {
addMachineError.value = humanizeError(result.error)
}
}
@@ -455,9 +503,19 @@ const confirmDeleteMachine = async (machine) => {
}
}
const openAddMachineModal = () => {
addMachineError.value = null
showAddMachineModal.value = true
}
const closeAddMachineModal = () => {
addMachineError.value = null
showAddMachineModal.value = false
}
const addMachineToSite = (site) => {
preselectedSiteId.value = site.id
showAddMachineModal.value = true
openAddMachineModal()
}
// Lifecycle
+13
View File
@@ -20,6 +20,19 @@
</div>
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
<div v-if="c.createError" class="alert alert-error" role="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
</svg>
<span>{{ c.createError }}</span>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-6">
<!-- Basic fields -->
@@ -157,6 +157,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadPieceTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de pièce mise à jour avec succès.')
}
} catch (error) {
@@ -181,6 +182,7 @@ const handleSyncConfirm = async () => {
confirmTypeChanges: !!hasModifications,
})
await loadPieceTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de pièce mise à jour avec succès.')
} catch (error) {
showError(normalizeError(error))
+39 -2
View File
@@ -1,12 +1,49 @@
import { formatPhone } from '~/utils/formatters/phone';
export interface ConstructeurTelephoneSummary {
numero?: string | null;
label?: string | null;
}
export interface ConstructeurSummary {
id: string;
name?: string | null;
email?: string | null;
// Legacy single-phone string: still exposed by the machine-structure normalization.
phone?: string | null;
// Multi-phone list: exposed by the /constructeurs API resource.
telephones?: ConstructeurTelephoneSummary[] | null;
}
type ConstructeurPhoneSource = {
phone?: string | null;
telephones?: ConstructeurTelephoneSummary[] | null;
} | null | undefined;
export const constructeurPhones = (
constructeur: ConstructeurPhoneSource,
): Array<{ numero: string; label: string | null }> => {
if (!constructeur) {
return [];
}
const list = Array.isArray(constructeur.telephones)
? constructeur.telephones
.filter((t): t is ConstructeurTelephoneSummary => Boolean(t && t.numero && String(t.numero).trim()))
.map(t => ({ numero: String(t.numero).trim(), label: (t.label ?? null) || null }))
: [];
if (!list.length && constructeur.phone && constructeur.phone.trim()) {
return [{ numero: constructeur.phone.trim(), label: null }];
}
return list;
};
export const constructeurPrimaryPhone = (
constructeur: ConstructeurPhoneSource,
): string | null => {
const phones = constructeurPhones(constructeur);
return phones.length ? phones[0]!.numero : null;
};
export interface ConstructeurLinkEntry {
linkId?: string;
constructeurId: string;
@@ -133,8 +170,8 @@ export const formatConstructeurContact = (
return '';
}
const formattedPhone = formatPhone(constructeur.phone);
const phone = formattedPhone || constructeur.phone || null;
const primary = constructeurPrimaryPhone(constructeur);
const phone = formatPhone(primary) || primary || null;
return [constructeur.email, phone].filter(Boolean).join(' • ');
};
@@ -14,6 +14,77 @@ export const buildDeleteMessage = (entityName: string, impacts: string[]): strin
if (impacts.length) {
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.')
return lines.join('\n\n')
}
@@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mockLinkSKF, mockLinkFAG } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentCreate } from '~/composables/useComponentCreate'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
@@ -206,12 +212,6 @@ vi.mock('~/shared/constructeurUtils', () => ({
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentCreate } from '~/composables/useComponentCreate'
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -8,6 +8,12 @@ import {
wrapCollection,
} from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentEdit } from '~/composables/useComponentEdit'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
@@ -222,12 +228,6 @@ vi.mock('~/utils/documentPreview', () => ({
canPreviewDocument: () => false,
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentEdit } from '~/composables/useComponentEdit'
// ---------------------------------------------------------------------------
// Test data — component with structure containing slots
// ---------------------------------------------------------------------------
@@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { wrapCollection } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useDocuments } from '~/composables/useDocuments'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
@@ -40,12 +46,6 @@ vi.mock('~/composables/useToast', () => ({
}),
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useDocuments } from '~/composables/useDocuments'
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
@@ -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 })
})
})
@@ -1,6 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
// ---------------------------------------------------------------------------
// Import under test (after mocks)
// ---------------------------------------------------------------------------
import { useMachineDetailData } from '~/composables/useMachineDetailData'
// ---------------------------------------------------------------------------
// Mock data — realistic /machines/{id}/structure response
// ---------------------------------------------------------------------------
@@ -345,12 +351,6 @@ vi.mock('~/shared/utils/documentDisplayUtils', () => ({
downloadDocument: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Import under test (after mocks)
// ---------------------------------------------------------------------------
import { useMachineDetailData } from '~/composables/useMachineDetailData'
// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------
@@ -9,6 +9,12 @@ import {
wrapCollection,
} from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { usePieceEdit } from '~/composables/usePieceEdit'
// ---------------------------------------------------------------------------
// Mocks — API layer
// ---------------------------------------------------------------------------
@@ -183,12 +189,6 @@ vi.mock('~/shared/apiRelations', () => ({
},
}))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { usePieceEdit } from '~/composables/usePieceEdit'
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260512150000_AddConstructeurCategoriesAndPhones extends AbstractMigration
{
public function getDescription(): string
{
return 'Add constructeur_categorie + constructeur_categories (M2M) + constructeur_telephone (1-N); migrate constructeurs.phone into constructeur_telephone then drop the phone column';
}
public function up(Schema $schema): void
{
// 1. Référentiel de catégories de fournisseurs.
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS constructeur_categorie (
id VARCHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
// 2. Table de jointure many-to-many fournisseur <-> catégorie.
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS constructeur_categories (
constructeur_id VARCHAR(36) NOT NULL REFERENCES constructeurs(id) ON DELETE CASCADE,
categorie_id VARCHAR(36) NOT NULL REFERENCES constructeur_categorie(id) ON DELETE CASCADE,
PRIMARY KEY(constructeur_id, categorie_id)
)
SQL);
$this->addSql('CREATE INDEX IF NOT EXISTS idx_constructeur_categories_categorie ON constructeur_categories (categorie_id)');
// 3. Téléphones (un fournisseur peut en avoir plusieurs).
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS constructeur_telephone (
id VARCHAR(36) NOT NULL PRIMARY KEY,
constructeurid VARCHAR(36) NOT NULL REFERENCES constructeurs(id) ON DELETE CASCADE,
numero VARCHAR(50) NOT NULL,
label VARCHAR(100) DEFAULT NULL,
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
$this->addSql('CREATE INDEX IF NOT EXISTS idx_constructeur_telephone_constructeur ON constructeur_telephone (constructeurid)');
// 4. Migration des téléphones existants (colonne unique) vers la nouvelle table.
$this->addSql(<<<'SQL'
INSERT INTO constructeur_telephone (id, constructeurid, numero, label, createdat, updatedat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text || c.id), 1, 24),
c.id,
trim(c.phone),
NULL,
NOW(),
NOW()
FROM constructeurs c
WHERE c.phone IS NOT NULL AND trim(c.phone) <> ''
SQL);
// 5. La colonne unique n'est plus la source de vérité.
$this->addSql('ALTER TABLE constructeurs DROP COLUMN IF EXISTS phone');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE constructeurs ADD COLUMN IF NOT EXISTS phone VARCHAR(255) DEFAULT NULL');
// Restaure un téléphone par fournisseur (le plus récemment créé), best-effort.
$this->addSql(<<<'SQL'
UPDATE constructeurs c
SET phone = t.numero
FROM (
SELECT DISTINCT ON (constructeurid) constructeurid, numero
FROM constructeur_telephone
ORDER BY constructeurid, createdat DESC
) t
WHERE t.constructeurid = c.id
SQL);
$this->addSql('DROP TABLE IF EXISTS constructeur_telephone');
$this->addSql('DROP TABLE IF EXISTS constructeur_categories');
$this->addSql('DROP TABLE IF EXISTS constructeur_categorie');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260527140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Machine name uniqueness is now scoped per site: drop global unique index on machines(name), add composite unique index on (name, siteid)';
}
public function up(Schema $schema): void
{
// Drop the global unique index/constraint on machines(name).
// Doctrine-generated name (CRC32 of table+column): uniq_f1ce8ded5e237e06.
// It may exist either as a constraint or as a bare index depending on origin,
// so we drop defensively in both forms.
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS uniq_f1ce8ded5e237e06');
$this->addSql('DROP INDEX IF EXISTS uniq_f1ce8ded5e237e06');
// Defensive fallbacks for other possible legacy names of the global unique index on name.
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS machines_name_key');
$this->addSql('DROP INDEX IF EXISTS machines_name_key');
// New uniqueness scope: a machine name is unique within a given site only.
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_machine_name_site ON machines (name, siteid)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS uniq_machine_name_site');
// Best-effort restore of the global unique index on machines(name).
// WARNING: this will fail if duplicate names now exist across sites (which the
// per-site scope allowed). Resolve duplicates manually before rolling back.
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_f1ce8ded5e237e06 ON machines (name)');
}
}
@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Align all FKs pointing to `pieces.id` with what entities declare
* (ON DELETE CASCADE / SET NULL). Cleans up pre-existing orphan rows
* inserted before the constraints existed, so the new FKs can be added.
*
* Mirror of Version20260506140000_FixComposantCascadeFKs for the Piece side.
*/
final class Version20260528090000_FixPieceCascadeFKs extends AbstractMigration
{
public function getDescription(): string
{
return 'Align CASCADE/SET NULL FKs on pieces references (machine_piece_links, composant_piece_slots, piece_product_slots, documents, custom_field_values, piece_constructeur_links); cleanup pre-existing orphans';
}
public function up(Schema $schema): void
{
// =========================================================================
// 1. Audit log : snapshot des rows orphelines avant suppression.
// =========================================================================
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'machine_piece_link',
l.id,
'delete',
json_build_object(
'id', l.id,
'machineId', l.machineid,
'pieceId', l.pieceid,
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
),
NULL,
NOW()
FROM machine_piece_links l
WHERE l.pieceid IS NOT NULL
AND l.pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'piece_product_slot',
s.id,
'delete',
json_build_object(
'id', s.id,
'pieceId', s.pieceid,
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
),
NULL,
NOW()
FROM piece_product_slots s
WHERE s.pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'document',
d.id,
'delete',
json_build_object(
'id', d.id,
'name', d.name,
'filename', d.filename,
'pieceId', d.pieceid,
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
),
NULL,
NOW()
FROM documents d
WHERE d.pieceid IS NOT NULL
AND d.pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'custom_field_value',
v.id,
'delete',
json_build_object(
'id', v.id,
'pieceId', v.pieceid,
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
),
NULL,
NOW()
FROM custom_field_values v
WHERE v.pieceid IS NOT NULL
AND v.pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'piece_constructeur_link',
l.id,
'delete',
json_build_object(
'id', l.id,
'pieceId', l.pieceid,
'constructeurId', l.constructeurid,
'note', 'Cleaned by FK cascade fix migration (Version20260528090000) - referenced piece no longer existed'
),
NULL,
NOW()
FROM piece_constructeur_links l
WHERE l.pieceid NOT IN (SELECT id FROM pieces)
SQL);
// =========================================================================
// 2. Nettoyage des orphelins (avant ADD CONSTRAINT, sinon PG rejette).
// =========================================================================
$this->addSql(<<<'SQL'
DELETE FROM machine_piece_links
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
UPDATE composant_piece_slots SET selectedpieceid = NULL
WHERE selectedpieceid IS NOT NULL
AND selectedpieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
DELETE FROM piece_product_slots
WHERE pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
DELETE FROM documents
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
DELETE FROM custom_field_values
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
DELETE FROM piece_constructeur_links
WHERE pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
DELETE FROM piece_products
WHERE piece_id NOT IN (SELECT id FROM pieces)
SQL);
// =========================================================================
// 3. Drop des éventuelles FK existantes vers `pieces` (quel que soit leur
// nom historique), puis ADD CONSTRAINT avec le bon ON DELETE.
// =========================================================================
$this->dropFksReferencingPieces('machine_piece_links', 'pieceid');
$this->addSql(<<<'SQL'
ALTER TABLE machine_piece_links ADD CONSTRAINT fk_mpl_piece
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
SQL);
$this->dropFksReferencingPieces('composant_piece_slots', 'selectedpieceid');
$this->addSql(<<<'SQL'
ALTER TABLE composant_piece_slots ADD CONSTRAINT fk_cps_selected_piece
FOREIGN KEY (selectedpieceid) REFERENCES pieces(id) ON DELETE SET NULL
SQL);
$this->dropFksReferencingPieces('piece_product_slots', 'pieceid');
$this->addSql(<<<'SQL'
ALTER TABLE piece_product_slots ADD CONSTRAINT fk_pps_piece
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
SQL);
$this->dropFksReferencingPieces('documents', 'pieceid');
$this->addSql(<<<'SQL'
ALTER TABLE documents ADD CONSTRAINT fk_documents_piece
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
SQL);
$this->dropFksReferencingPieces('custom_field_values', 'pieceid');
$this->addSql(<<<'SQL'
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_piece
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
SQL);
$this->dropFksReferencingPieces('piece_constructeur_links', 'pieceid');
$this->addSql(<<<'SQL'
ALTER TABLE piece_constructeur_links ADD CONSTRAINT fk_pcl_piece
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_mpl_piece');
$this->addSql('ALTER TABLE composant_piece_slots DROP CONSTRAINT IF EXISTS fk_cps_selected_piece');
$this->addSql('ALTER TABLE piece_product_slots DROP CONSTRAINT IF EXISTS fk_pps_piece');
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_piece');
$this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_piece');
$this->addSql('ALTER TABLE piece_constructeur_links DROP CONSTRAINT IF EXISTS fk_pcl_piece');
}
/**
* Drop every FK on $table.$column that references the `pieces` table,
* regardless of its historic name. Idempotent.
*/
private function dropFksReferencingPieces(string $table, string $column): void
{
$sql = <<<SQL
DO \$\$
DECLARE
fk_name TEXT;
BEGIN
FOR fk_name IN
SELECT tc.constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON kcu.constraint_name = tc.constraint_name
AND kcu.table_schema = tc.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.table_name = '{$table}'
AND tc.constraint_type = 'FOREIGN KEY'
AND kcu.column_name = '{$column}'
AND ccu.table_name = 'pieces'
LOOP
EXECUTE format('ALTER TABLE {$table} DROP CONSTRAINT %I', fk_name);
END LOOP;
END \$\$;
SQL;
$this->addSql($sql);
}
}
@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Repair migration for Version20260528090000_FixPieceCascadeFKs.
*
* On some environments (prod included) that migration was recorded as executed
* but two of its six FKs to `pieces.id` never took effect:
* - machine_piece_links.pieceid (fk_mpl_piece)
* - custom_field_values.pieceid (fk_cfv_piece)
* Without them, deleting a Piece leaves orphan rows behind (a stale pieceid
* pointing to a non-existent piece), which surfaces as a "Catégorie sans item"
* ghost on the machine detail page and a 404 on /documents/piece/{id}.
*
* This migration re-applies ONLY those two missing pieces of the original one:
* snapshot orphans to audit_logs, delete them, then (re)add the FK with the
* correct ON DELETE CASCADE. Fully idempotent — safe where the FKs already exist.
*/
final class Version20260529150000_AddMissingPieceCascadeFKs extends AbstractMigration
{
public function getDescription(): string
{
return 'Repair missing CASCADE FKs to pieces on machine_piece_links and custom_field_values (orphan cleanup + fk_mpl_piece / fk_cfv_piece)';
}
public function up(Schema $schema): void
{
// =========================================================================
// 1. Audit log : snapshot des rows orphelines avant suppression.
// =========================================================================
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'machine_piece_link',
l.id,
'delete',
json_build_object(
'id', l.id,
'machineId', l.machineid,
'pieceId', l.pieceid,
'note', 'Cleaned by FK cascade repair migration (Version20260529150000) - referenced piece no longer existed'
),
NULL,
NOW()
FROM machine_piece_links l
WHERE l.pieceid IS NOT NULL
AND l.pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'custom_field_value',
v.id,
'delete',
json_build_object(
'id', v.id,
'pieceId', v.pieceid,
'note', 'Cleaned by FK cascade repair migration (Version20260529150000) - referenced piece no longer existed'
),
NULL,
NOW()
FROM custom_field_values v
WHERE v.pieceid IS NOT NULL
AND v.pieceid NOT IN (SELECT id FROM pieces)
SQL);
// =========================================================================
// 2. Nettoyage des orphelins (avant ADD CONSTRAINT, sinon PG rejette).
// =========================================================================
$this->addSql(<<<'SQL'
DELETE FROM machine_piece_links
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
SQL);
$this->addSql(<<<'SQL'
DELETE FROM custom_field_values
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
SQL);
// =========================================================================
// 3. Drop des éventuelles FK existantes vers `pieces` (quel que soit leur
// nom historique), puis ADD CONSTRAINT avec le bon ON DELETE.
// =========================================================================
$this->dropFksReferencingPieces('machine_piece_links', 'pieceid');
$this->addSql(<<<'SQL'
ALTER TABLE machine_piece_links ADD CONSTRAINT fk_mpl_piece
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
SQL);
$this->dropFksReferencingPieces('custom_field_values', 'pieceid');
$this->addSql(<<<'SQL'
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_piece
FOREIGN KEY (pieceid) REFERENCES pieces(id) ON DELETE CASCADE
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_mpl_piece');
$this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_piece');
}
/**
* Drop every FK on $table.$column that references the `pieces` table,
* regardless of its historic name. Idempotent.
*/
private function dropFksReferencingPieces(string $table, string $column): void
{
$sql = <<<SQL
DO \$\$
DECLARE
fk_name TEXT;
BEGIN
FOR fk_name IN
SELECT tc.constraint_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON kcu.constraint_name = tc.constraint_name
AND kcu.table_schema = tc.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
AND ccu.table_schema = tc.table_schema
WHERE tc.table_name = '{$table}'
AND tc.constraint_type = 'FOREIGN KEY'
AND kcu.column_name = '{$column}'
AND ccu.table_name = 'pieces'
LOOP
EXECUTE format('ALTER TABLE {$table} DROP CONSTRAINT %I', fk_name);
END LOOP;
END \$\$;
SQL;
$this->addSql($sql);
}
}
+223
View File
@@ -0,0 +1,223 @@
-- =============================================================================
-- cleanup_orphan_piece_refs.sql
-- =============================================================================
-- Contexte : la suppression directe de rows dans `pieces` (bypass Doctrine /
-- FK DB sans ON DELETE CASCADE) laisse des références orphelines dans plusieurs
-- tables, ce qui fait planter l'API au chargement d'une Machine :
-- Doctrine\ORM\EntityNotFoundException: Entity of type 'App\Entity\Piece' ...
--
-- Ce script fait deux choses :
-- 1. ÉTAPE 1 (toujours exécutée) : compte les références orphelines par table
-- pour visualiser l'ampleur du problème.
-- 2. ÉTAPE 2 (commentée par défaut) : insère un audit_log par row, puis
-- DELETE / UPDATE SET NULL selon la sémantique attendue côté entité.
-- Décommenter le bloc `BEGIN; ... COMMIT;` pour appliquer.
--
-- Usage :
-- # Dry-run (compte seulement)
-- psql -h <host> -U <user> -d inventory -f scripts/cleanup_orphan_piece_refs.sql
--
-- # Application : décommenter le bloc transactionnel en bas du fichier,
-- # puis relancer la même commande. La transaction garantit l'atomicité.
-- =============================================================================
-- ============================== ÉTAPE 1 : DRY-RUN ============================
\echo ''
\echo '=== Orphelins par table (Pieces) ==='
SELECT 'machine_piece_links' AS table_name, count(*) AS orphans
FROM machine_piece_links
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
UNION ALL
SELECT 'composant_piece_slots', count(*)
FROM composant_piece_slots
WHERE selectedpieceid IS NOT NULL
AND selectedpieceid NOT IN (SELECT id FROM pieces)
UNION ALL
SELECT 'piece_product_slots', count(*)
FROM piece_product_slots
WHERE pieceid NOT IN (SELECT id FROM pieces)
UNION ALL
SELECT 'documents', count(*)
FROM documents
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
UNION ALL
SELECT 'custom_field_values', count(*)
FROM custom_field_values
WHERE pieceid IS NOT NULL
AND pieceid NOT IN (SELECT id FROM pieces)
UNION ALL
SELECT 'piece_constructeur_links', count(*)
FROM piece_constructeur_links
WHERE pieceid NOT IN (SELECT id FROM pieces)
UNION ALL
SELECT 'piece_products', count(*)
FROM piece_products
WHERE piece_id NOT IN (SELECT id FROM pieces)
ORDER BY table_name;
\echo ''
\echo '=> Pour appliquer le cleanup, décommenter le bloc BEGIN/COMMIT ci-dessous.'
\echo ''
-- ============================== ÉTAPE 2 : APPLY =============================
-- Décommenter ce bloc pour exécuter le cleanup. La transaction garantit
-- l'atomicité : tout passe, ou rien (en cas d'erreur, ROLLBACK auto).
--
-- BEGIN;
--
-- -- 1. Audit log : snapshot des rows qui vont être supprimées (traçabilité prod).
--
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
-- SELECT
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
-- 'machine_piece_link',
-- l.id,
-- 'delete',
-- json_build_object(
-- 'id', l.id,
-- 'machineId', l.machineid,
-- 'pieceId', l.pieceid,
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
-- ),
-- NULL,
-- NOW()
-- FROM machine_piece_links l
-- WHERE l.pieceid IS NOT NULL
-- AND l.pieceid NOT IN (SELECT id FROM pieces);
--
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
-- SELECT
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
-- 'piece_product_slot',
-- s.id,
-- 'delete',
-- json_build_object(
-- 'id', s.id,
-- 'pieceId', s.pieceid,
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
-- ),
-- NULL,
-- NOW()
-- FROM piece_product_slots s
-- WHERE s.pieceid NOT IN (SELECT id FROM pieces);
--
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
-- SELECT
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
-- 'document',
-- d.id,
-- 'delete',
-- json_build_object(
-- 'id', d.id,
-- 'name', d.name,
-- 'filename', d.filename,
-- 'pieceId', d.pieceid,
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
-- ),
-- NULL,
-- NOW()
-- FROM documents d
-- WHERE d.pieceid IS NOT NULL
-- AND d.pieceid NOT IN (SELECT id FROM pieces);
--
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
-- SELECT
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
-- 'custom_field_value',
-- v.id,
-- 'delete',
-- json_build_object(
-- 'id', v.id,
-- 'pieceId', v.pieceid,
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
-- ),
-- NULL,
-- NOW()
-- FROM custom_field_values v
-- WHERE v.pieceid IS NOT NULL
-- AND v.pieceid NOT IN (SELECT id FROM pieces);
--
-- INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
-- SELECT
-- 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
-- 'piece_constructeur_link',
-- l.id,
-- 'delete',
-- json_build_object(
-- 'id', l.id,
-- 'pieceId', l.pieceid,
-- 'constructeurId', l.constructeurid,
-- 'note', 'Cleaned by cleanup_orphan_piece_refs.sql - referenced piece no longer existed'
-- ),
-- NULL,
-- NOW()
-- FROM piece_constructeur_links l
-- WHERE l.pieceid NOT IN (SELECT id FROM pieces);
--
-- -- 2. Nettoyage des orphelins.
--
-- DELETE FROM machine_piece_links
-- WHERE pieceid IS NOT NULL
-- AND pieceid NOT IN (SELECT id FROM pieces);
--
-- UPDATE composant_piece_slots SET selectedpieceid = NULL
-- WHERE selectedpieceid IS NOT NULL
-- AND selectedpieceid NOT IN (SELECT id FROM pieces);
--
-- DELETE FROM piece_product_slots
-- WHERE pieceid NOT IN (SELECT id FROM pieces);
--
-- DELETE FROM documents
-- WHERE pieceid IS NOT NULL
-- AND pieceid NOT IN (SELECT id FROM pieces);
--
-- DELETE FROM custom_field_values
-- WHERE pieceid IS NOT NULL
-- AND pieceid NOT IN (SELECT id FROM pieces);
--
-- DELETE FROM piece_constructeur_links
-- WHERE pieceid NOT IN (SELECT id FROM pieces);
--
-- DELETE FROM piece_products
-- WHERE piece_id NOT IN (SELECT id FROM pieces);
--
-- -- 3. Vérification post-cleanup : tout doit être à 0.
-- SELECT 'machine_piece_links' AS table_name, count(*) AS remaining_orphans
-- FROM machine_piece_links
-- WHERE pieceid IS NOT NULL
-- AND pieceid NOT IN (SELECT id FROM pieces)
-- UNION ALL
-- SELECT 'composant_piece_slots', count(*)
-- FROM composant_piece_slots
-- WHERE selectedpieceid IS NOT NULL
-- AND selectedpieceid NOT IN (SELECT id FROM pieces)
-- UNION ALL
-- SELECT 'piece_product_slots', count(*)
-- FROM piece_product_slots
-- WHERE pieceid NOT IN (SELECT id FROM pieces)
-- UNION ALL
-- SELECT 'documents', count(*)
-- FROM documents
-- WHERE pieceid IS NOT NULL
-- AND pieceid NOT IN (SELECT id FROM pieces)
-- UNION ALL
-- SELECT 'custom_field_values', count(*)
-- FROM custom_field_values
-- WHERE pieceid IS NOT NULL
-- AND pieceid NOT IN (SELECT id FROM pieces)
-- UNION ALL
-- SELECT 'piece_constructeur_links', count(*)
-- FROM piece_constructeur_links
-- WHERE pieceid NOT IN (SELECT id FROM pieces)
-- UNION ALL
-- SELECT 'piece_products', count(*)
-- FROM piece_products
-- WHERE piece_id NOT IN (SELECT id FROM pieces)
-- ORDER BY table_name;
--
-- COMMIT;
+295
View File
@@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Constructeur;
use App\Entity\ConstructeurCategorie;
use App\Entity\ConstructeurTelephone;
use Doctrine\ORM\EntityManagerInterface;
use SplObjectStorage;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Importe un référentiel de fournisseurs depuis un fichier JSON de la forme
* {"count": N, "data": [{"reference": "...", "name": "...", "categoriesStr": "a, b", "organizationsStr": "...", "phone": "..."}, ...]}.
*
* Règles : on garde l'existant. Si un fournisseur du fichier porte le même nom (insensible à la casse/aux espaces)
* qu'un fournisseur déjà en base, on le complète sans changer son id : on n'ajoute que les catégories et les
* téléphones manquants, on n'écrase ni ne supprime jamais rien.
*/
#[AsCommand(
name: 'app:import-fournisseurs',
description: 'Importe/complète les fournisseurs depuis un fichier JSON (customer.json par défaut). Dry-run par défaut : utiliser --force pour écrire.',
)]
class ImportFournisseursCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('file', InputArgument::OPTIONAL, 'Chemin du fichier JSON', 'customer.json')
->addOption('force', null, InputOption::VALUE_NONE, 'Écrit réellement en base (sinon dry-run)')
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Ne traiter que les N premières entrées (debug)')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$write = (bool) $input->getOption('force');
$limit = null !== $input->getOption('limit') ? max(0, (int) $input->getOption('limit')) : null;
$path = (string) $input->getArgument('file');
if (!str_starts_with($path, '/')) {
$path = rtrim($this->projectDir, '/').'/'.$path;
}
if (!is_file($path) || !is_readable($path)) {
$io->error(sprintf('Fichier introuvable ou illisible : %s', $path));
return Command::FAILURE;
}
$raw = file_get_contents($path);
$decoded = json_decode((string) $raw, true);
if (!is_array($decoded) || !isset($decoded['data']) || !is_array($decoded['data'])) {
$io->error('JSON invalide : la clé "data" (tableau) est attendue.');
return Command::FAILURE;
}
/** @var array<int, array<string, mixed>> $rows */
$rows = $decoded['data'];
if (null !== $limit) {
$rows = array_slice($rows, 0, $limit);
}
$io->title('Import fournisseurs');
$io->writeln(sprintf('Fichier : <info>%s</info>', $path));
$io->writeln(sprintf('Entrées : <info>%d</info>', count($rows)));
$io->writeln($write ? '<comment>Mode écriture (--force)</comment>' : '<comment>Mode dry-run — aucune écriture. Ajouter --force pour appliquer.</comment>');
$io->newLine();
// --- Chargement des référentiels existants ---------------------------------
/** @var array<string, Constructeur> $constructeursByName */
$constructeursByName = [];
foreach ($this->em->getRepository(Constructeur::class)->findAll() as $c) {
$constructeursByName[$this->normalizeKey((string) $c->getName())] = $c;
}
/** @var array<string, ConstructeurCategorie> $categoriesByName */
$categoriesByName = [];
foreach ($this->em->getRepository(ConstructeurCategorie::class)->findAll() as $cat) {
$categoriesByName[$this->normalizeKey((string) $cat->getName())] = $cat;
}
// numéros et liens catégorie déjà présents, indexés par objet Constructeur
$seenNumeros = new SplObjectStorage(); // Constructeur => array<string,true> (clé = numéro normalisé)
$seenCatLinks = new SplObjectStorage(); // Constructeur => array<string,true> (clé = nom catégorie normalisé)
// pré-remplissage pour les fournisseurs existants
$existingTel = $this->em->getRepository(ConstructeurTelephone::class)->findAll();
foreach ($existingTel as $tel) {
$owner = $tel->getConstructeur();
if (null === $owner) {
continue;
}
$map = $seenNumeros[$owner] ?? [];
$map[$this->normalizeKey((string) $tel->getNumero())] = true;
$seenNumeros[$owner] = $map;
}
/** @var array<int, array{cname: string, catname: string}> $catLinkPairs */
$catLinkPairs = $this->em->createQuery(
'SELECT c.name AS cname, cat.name AS catname FROM '.Constructeur::class.' c JOIN c.categories cat'
)->getArrayResult();
foreach ($catLinkPairs as $pair) {
$cKey = $this->normalizeKey((string) $pair['cname']);
$catKey = $this->normalizeKey((string) $pair['catname']);
$owner = $constructeursByName[$cKey] ?? null;
if (null === $owner) {
continue;
}
$map = $seenCatLinks[$owner] ?? [];
$map[$catKey] = true;
$seenCatLinks[$owner] = $map;
}
// --- Traitement ------------------------------------------------------------
$created = 0;
$matched = 0;
$phonesAdded = 0;
$categoriesCreated = 0;
$catLinksAdded = 0;
$skippedNoName = 0;
$tooLong = [];
$i = 0;
foreach ($rows as $row) {
++$i;
$name = trim((string) ($row['name'] ?? $row['reference'] ?? ''));
if ('' === $name) {
++$skippedNoName;
continue;
}
if (mb_strlen($name) > 255) {
$tooLong[] = $name;
$name = mb_substr($name, 0, 255);
}
$key = $this->normalizeKey($name);
if (isset($constructeursByName[$key])) {
$constructeur = $constructeursByName[$key];
++$matched;
} else {
$constructeur = new Constructeur()->setName($name);
if ($write) {
$this->em->persist($constructeur);
}
$constructeursByName[$key] = $constructeur;
++$created;
}
// --- téléphones ---
foreach ($this->splitPhones((string) ($row['phone'] ?? '')) as $numero) {
$numero = mb_substr($numero, 0, 50);
$nKey = $this->normalizeKey($numero);
$map = $seenNumeros[$constructeur] ?? [];
if (isset($map[$nKey])) {
continue;
}
$tel = new ConstructeurTelephone()->setNumero($numero);
$constructeur->addTelephone($tel);
if ($write) {
$this->em->persist($tel);
}
$map[$nKey] = true;
$seenNumeros[$constructeur] = $map;
++$phonesAdded;
}
// --- catégories ---
foreach ($this->splitCategories((string) ($row['categoriesStr'] ?? '')) as $catName) {
$catName = mb_substr($catName, 0, 255);
$catKey = $this->normalizeKey($catName);
if (isset($categoriesByName[$catKey])) {
$categorie = $categoriesByName[$catKey];
} else {
$categorie = new ConstructeurCategorie()->setName($catName);
if ($write) {
$this->em->persist($categorie);
}
$categoriesByName[$catKey] = $categorie;
++$categoriesCreated;
}
$linkMap = $seenCatLinks[$constructeur] ?? [];
if (isset($linkMap[$catKey])) {
continue;
}
$constructeur->addCategory($categorie);
$linkMap[$catKey] = true;
$seenCatLinks[$constructeur] = $linkMap;
++$catLinksAdded;
}
if ($write && 0 === $i % 200) {
$this->em->flush();
}
}
if ($write) {
$this->em->flush();
}
// --- Rapport ---------------------------------------------------------------
$io->section('Résultat');
$io->table(
['Action', 'Nombre'],
[
['Fournisseurs créés', $created],
['Fournisseurs déjà en base (complétés si besoin)', $matched],
['Téléphones ajoutés', $phonesAdded],
['Catégories créées', $categoriesCreated],
['Liens fournisseur↔catégorie ajoutés', $catLinksAdded],
['Entrées ignorées (sans nom)', $skippedNoName],
['Noms tronqués (>255)', count($tooLong)],
]
);
if ($tooLong) {
$io->warning(sprintf('%d nom(s) dépassaient 255 caractères et ont été tronqués.', count($tooLong)));
}
if ($write) {
$io->success('Import terminé.');
} else {
$io->note('Dry-run : rien n\'a été écrit. Relancer avec --force pour appliquer.');
}
return Command::SUCCESS;
}
/**
* @return list<string>
*/
private function splitPhones(string $value): array
{
$parts = preg_split('#[/;\n\r]+#', $value) ?: [];
$out = [];
foreach ($parts as $p) {
$p = trim($p);
if ('' !== $p) {
$out[] = $p;
}
}
return array_values(array_unique($out));
}
/**
* @return list<string>
*/
private function splitCategories(string $value): array
{
$parts = explode(',', $value);
$out = [];
$seen = [];
foreach ($parts as $p) {
$p = trim($p);
if ('' === $p) {
continue;
}
$k = $this->normalizeKey($p);
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[] = $p;
}
return $out;
}
private function normalizeKey(string $value): string
{
return mb_strtolower(trim(preg_replace('/\s+/u', ' ', $value) ?? $value));
}
}
+47 -5
View File
@@ -6,6 +6,7 @@ namespace App\Controller;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use App\Repository\ComposantRepository;
use App\Repository\CustomFieldRepository;
use App\Repository\CustomFieldValueRepository;
@@ -15,6 +16,7 @@ use App\Repository\MachineRepository;
use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -288,28 +290,68 @@ class CustomFieldValueController extends AbstractController
case 'machinePieceLink':
$value->setMachinePieceLink($entity);
$value->setPiece($entity->getPiece());
$value->setPiece($this->ensurePieceExists($entity->getPiece()));
break;
}
}
/**
* Returns the Piece if its underlying row still exists in DB, otherwise null.
* getId() on a Doctrine proxy does NOT trigger __load(), so we force the proxy
* to initialize explicitly to handle orphan links here instead of crashing on
* the first real getter.
*/
private function ensurePieceExists(?Piece $piece): ?Piece
{
if (null === $piece) {
return null;
}
try {
$this->entityManager->initializeObject($piece);
return $piece;
} catch (EntityNotFoundException) {
return null;
}
}
/**
* getId() on a Doctrine proxy returns the identifier without triggering __load(),
* so it never raises EntityNotFoundException even if the row is gone. Force the
* proxy to initialize explicitly so an orphan CFV is handled here instead of
* crashing on the first real getter.
*/
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
{
if (null === $cf) {
return null;
}
try {
$this->entityManager->initializeObject($cf);
return $cf;
} catch (EntityNotFoundException) {
return null;
}
}
private function normalizeCustomFieldValue(CustomFieldValue $value): array
{
$customField = $value->getCustomField();
$customField = $this->ensureCustomFieldExists($value->getCustomField());
return [
'id' => $value->getId(),
'value' => $value->getValue(),
'customFieldId' => $customField->getId(),
'customField' => [
'customFieldId' => $customField?->getId(),
'customField' => $customField ? [
'id' => $customField->getId(),
'name' => $customField->getName(),
'type' => $customField->getType(),
'required' => $customField->isRequired(),
'options' => $customField->getOptions(),
'orderIndex' => $customField->getOrderIndex(),
],
] : null,
'machineId' => $value->getMachine()?->getId(),
'composantId' => $value->getComposant()?->getId(),
'pieceId' => $value->getPiece()?->getId(),
+62 -8
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller;
use App\Entity\Composant;
use App\Entity\Constructeur;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
@@ -25,6 +26,7 @@ use App\Repository\PieceRepository;
use App\Repository\ProductRepository;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
@@ -675,7 +677,7 @@ class MachineStructureController extends AbstractController
private function normalizePieceLinks(array $links): array
{
return array_map(function (MachinePieceLink $link): array {
$piece = $link->getPiece();
$piece = $this->ensurePieceExists($link->getPiece());
$modelType = $link->getModelType();
$parentLink = $link->getParentLink();
$type = $piece?->getTypePiece();
@@ -703,7 +705,7 @@ class MachineStructureController extends AbstractController
private function resolvePieceQuantity(MachinePieceLink $link): int
{
$parentLink = $link->getParentLink();
$piece = $link->getPiece();
$piece = $this->ensurePieceExists($link->getPiece());
if (!$parentLink || !$piece) {
return $link->getQuantity();
@@ -715,7 +717,8 @@ class MachineStructureController extends AbstractController
}
foreach ($composant->getPieceSlots() as $slot) {
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
$selected = $this->ensurePieceExists($slot->getSelectedPiece());
if ($selected?->getId() === $piece->getId()) {
return $slot->getQuantity();
}
}
@@ -770,15 +773,16 @@ class MachineStructureController extends AbstractController
{
$pieces = [];
foreach ($composant->getPieceSlots() as $slot) {
$selectedPiece = $this->ensurePieceExists($slot->getSelectedPiece());
$pieceData = [
'slotId' => $slot->getId(),
'typePieceId' => $slot->getTypePiece()?->getId(),
'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
'quantity' => $slot->getQuantity(),
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
'selectedPieceId' => $selectedPiece?->getId(),
];
if ($slot->getSelectedPiece()) {
$pieceData['resolvedPiece'] = $this->normalizePiece($slot->getSelectedPiece());
if ($selectedPiece) {
$pieceData['resolvedPiece'] = $this->normalizePiece($selectedPiece);
}
$pieces[] = $pieceData;
}
@@ -809,6 +813,46 @@ class MachineStructureController extends AbstractController
];
}
/**
* Returns the Piece if its underlying row still exists in DB, otherwise null.
* getId() on a Doctrine proxy does NOT trigger __load() (the id is the key used
* to build the proxy), so we force initialization via initializeObject() to
* surface a stale FK here instead of crashing on the first real getter.
*/
private function ensurePieceExists(?Piece $piece): ?Piece
{
if (null === $piece) {
return null;
}
try {
$this->entityManager->initializeObject($piece);
return $piece;
} catch (EntityNotFoundException) {
return null;
}
}
/**
* Returns the CustomField if its underlying row still exists, otherwise null.
* getId() on a Doctrine proxy does NOT trigger __load() — the id is the key used
* to build the proxy. We force initialization explicitly so a stale FK to a
* deleted CustomField surfaces here instead of crashing on getName() later.
*/
private function ensureCustomFieldExists(?CustomField $cf): ?CustomField
{
if (null === $cf) {
return null;
}
try {
$this->entityManager->initializeObject($cf);
return $cf;
} catch (EntityNotFoundException) {
return null;
}
}
private function normalizePiece(Piece $piece): array
{
$type = $piece->getTypePiece();
@@ -872,7 +916,7 @@ class MachineStructureController extends AbstractController
'id' => $link->getConstructeur()->getId(),
'name' => $link->getConstructeur()->getName(),
'email' => $link->getConstructeur()->getEmail(),
'phone' => $link->getConstructeur()->getPhone(),
'phone' => $this->constructeurPhone($link->getConstructeur()),
],
'supplierReference' => $link->getSupplierReference(),
];
@@ -881,6 +925,13 @@ class MachineStructureController extends AbstractController
return $items;
}
private function constructeurPhone(Constructeur $constructeur): ?string
{
$first = $constructeur->getTelephones()->first();
return false !== $first ? $first->getNumero() : null;
}
private function normalizeCustomFieldDefinitions(Collection $customFields): array
{
$items = [];
@@ -912,7 +963,10 @@ class MachineStructureController extends AbstractController
if (!$cfv instanceof CustomFieldValue) {
continue;
}
$cf = $cfv->getCustomField();
$cf = $this->ensureCustomFieldExists($cfv->getCustomField());
if (null === $cf) {
continue;
}
$items[] = [
'id' => $cfv->getId(),
'value' => $cfv->getValue(),
+80 -8
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
@@ -12,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Filter\ConstructeurSearchFilter;
use App\Repository\ConstructeurRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -19,12 +23,16 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')]
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
#[ORM\Table(name: 'constructeurs')]
#[ORM\HasLifecycleCallbacks]
#[ApiFilter(ConstructeurSearchFilter::class)]
#[ApiFilter(SearchFilter::class, properties: ['categories.id' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'email', 'createdAt'])]
#[ApiResource(
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
operations: [
@@ -36,7 +44,9 @@ use Symfony\Component\Validator\Constraints as Assert;
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200
paginationMaximumItemsPerPage: 2000,
normalizationContext: ['groups' => ['constructeur:read']],
denormalizationContext: ['groups' => ['constructeur:write']]
)]
class Constructeur
{
@@ -44,24 +54,43 @@ class Constructeur
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['constructeur:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
#[Groups(['constructeur:read', 'constructeur:write'])]
private ?string $name = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[Groups(['constructeur:read', 'constructeur:write'])]
private ?string $email = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $phone = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['constructeur:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
#[Groups(['constructeur:read'])]
private DateTimeImmutable $updatedAt;
/**
* @var Collection<int, ConstructeurTelephone>
*/
#[ORM\OneToMany(mappedBy: 'constructeur', targetEntity: ConstructeurTelephone::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['constructeur:read', 'constructeur:write'])]
private Collection $telephones;
/**
* @var Collection<int, ConstructeurCategorie>
*/
#[ORM\ManyToMany(targetEntity: ConstructeurCategorie::class, inversedBy: 'constructeurs')]
#[ORM\JoinTable(name: 'constructeur_categories')]
#[ORM\JoinColumn(name: 'constructeur_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'categorie_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[Groups(['constructeur:read', 'constructeur:write'])]
private Collection $categories;
/**
* @var Collection<int, MachineConstructeurLink>
*/
@@ -94,6 +123,8 @@ class Constructeur
$this->composantLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection();
$this->telephones = new ArrayCollection();
$this->categories = new ArrayCollection();
}
public function getName(): ?string
@@ -120,14 +151,55 @@ class Constructeur
return $this;
}
public function getPhone(): ?string
/**
* @return Collection<int, ConstructeurTelephone>
*/
public function getTelephones(): Collection
{
return $this->phone;
return $this->telephones;
}
public function setPhone(?string $phone): static
public function addTelephone(ConstructeurTelephone $telephone): static
{
$this->phone = $phone;
if (!$this->telephones->contains($telephone)) {
$this->telephones->add($telephone);
$telephone->setConstructeur($this);
}
return $this;
}
public function removeTelephone(ConstructeurTelephone $telephone): static
{
if ($this->telephones->removeElement($telephone)) {
if ($telephone->getConstructeur() === $this) {
$telephone->setConstructeur(null);
}
}
return $this;
}
/**
* @return Collection<int, ConstructeurCategorie>
*/
public function getCategories(): Collection
{
return $this->categories;
}
public function addCategory(ConstructeurCategorie $category): static
{
if (!$this->categories->contains($category)) {
$this->categories->add($category);
}
return $this;
}
public function removeCategory(ConstructeurCategorie $category): static
{
$this->categories->removeElement($category);
return $this;
}
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ConstructeurCategorieRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(fields: ['name'], message: 'Une catégorie de fournisseur avec ce nom existe déjà.')]
#[ORM\Entity(repositoryClass: ConstructeurCategorieRepository::class)]
#[ORM\Table(name: 'constructeur_categorie')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Catégories de fournisseurs (ex. organisme de formation, transporteur, agence d\'intérim). Référentiel partagé : une même catégorie peut être rattachée à plusieurs fournisseurs.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 1000,
order: ['name' => 'ASC']
)]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial'])]
#[ApiFilter(OrderFilter::class, properties: ['name'])]
class ConstructeurCategorie
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['constructeur:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
#[Groups(['constructeur:read'])]
private ?string $name = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt;
/**
* @var Collection<int, Constructeur>
*/
#[ORM\ManyToMany(targetEntity: Constructeur::class, mappedBy: 'categories')]
private Collection $constructeurs;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->constructeurs = new ArrayCollection();
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
}
+109
View File
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ConstructeurTelephoneRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ConstructeurTelephoneRepository::class)]
#[ORM\Table(name: 'constructeur_telephone')]
#[ORM\Index(name: 'idx_constructeur_telephone_constructeur', columns: ['constructeurid'])]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Numéro de téléphone rattaché à un fournisseur. Un fournisseur peut en avoir plusieurs (standard, mobile, comptabilité…).',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
]
)]
#[ApiFilter(SearchFilter::class, properties: ['constructeur' => 'exact'])]
class ConstructeurTelephone
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['constructeur:read'])]
private ?string $id = null;
#[ORM\ManyToOne(targetEntity: Constructeur::class, inversedBy: 'telephones')]
#[ORM\JoinColumn(name: 'constructeurId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Constructeur $constructeur = null;
#[ORM\Column(type: Types::STRING, length: 50)]
#[Assert\NotBlank(message: 'Le numéro de téléphone est obligatoire.')]
#[Groups(['constructeur:read', 'constructeur:write'])]
private ?string $numero = null;
#[ORM\Column(type: Types::STRING, length: 100, nullable: true)]
#[Groups(['constructeur:read', 'constructeur:write'])]
private ?string $label = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
public function getConstructeur(): ?Constructeur
{
return $this->constructeur;
}
public function setConstructeur(?Constructeur $constructeur): static
{
$this->constructeur = $constructeur;
return $this;
}
public function getNumero(): ?string
{
return $this->numero;
}
public function setNumero(string $numero): static
{
$this->numero = $numero;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(?string $label): static
{
$this->label = $label;
return $this;
}
}
+3 -1
View File
@@ -24,8 +24,10 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: MachineRepository::class)]
#[ORM\Table(name: 'machines')]
#[ORM\UniqueConstraint(name: 'uniq_machine_name_site', columns: ['name', 'siteId'])]
#[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.', ignoreNull: true)]
#[UniqueEntity(fields: ['name', 'site'], message: 'Une machine avec ce nom existe déjà sur ce site.')]
#[ApiResource(
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.',
operations: [
@@ -45,7 +47,7 @@ class Machine
#[Groups(['document:list'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['document:list'])]
private string $name;
@@ -18,6 +18,7 @@ use DateTimeInterface;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityNotFoundException;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
use Doctrine\ORM\UnitOfWork;
@@ -432,7 +433,12 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
return;
}
$fieldName = 'customField:'.$cfv->getCustomField()->getName();
try {
$cfName = $cfv->getCustomField()->getName();
} catch (EntityNotFoundException) {
return;
}
$fieldName = 'customField:'.$cfName;
$diff = [$fieldName => ['from' => $from, 'to' => $to]];
$pendingUpdates[$ownerId] = $this->mergeDiffs($pendingUpdates[$ownerId] ?? [], $diff);
@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\Constructeur;
use App\Entity\ConstructeurCategorie;
use App\Entity\ConstructeurTelephone;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Events;
#[AsDoctrineListener(event: Events::onFlush)]
@@ -23,11 +26,21 @@ final class ConstructeurAuditSubscriber extends AbstractAuditSubscriber
protected function snapshotEntity(object $entity): array
{
$telephones = $this->safeGet($entity, 'getTelephones');
$categories = $this->safeGet($entity, 'getCategories');
return [
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'email' => $this->safeGet($entity, 'getEmail'),
'phone' => $this->safeGet($entity, 'getPhone'),
'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'),
'email' => $this->safeGet($entity, 'getEmail'),
'telephones' => $telephones instanceof Collection ? array_values(array_map(
static fn (ConstructeurTelephone $t): array => ['numero' => $t->getNumero(), 'label' => $t->getLabel()],
$telephones->toArray(),
)) : [],
'categories' => $categories instanceof Collection ? array_values(array_filter(array_map(
static fn (ConstructeurCategorie $c): ?string => $c->getName(),
$categories->toArray(),
))) : [],
];
}
}
@@ -30,8 +30,9 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
$constraint = $this->detectConstraintName($exception);
$error = match ($constraint) {
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
default => 'Un élément avec cette valeur existe déjà.',
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
'uniq_machine_name_site' => 'Une machine avec ce nom existe déjà sur ce site.',
default => 'Un élément avec cette valeur existe déjà.',
};
$event->setResponse(new JsonResponse(
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\ConstructeurTelephone;
use Doctrine\ORM\QueryBuilder;
/**
* Search filter pour Constructeur : LIKE insensible à la casse sur name, email
* + LEFT JOIN sur la collection telephones pour matcher aussi sur telephone.numero.
* Param query : ?search=...
*/
final class ConstructeurSearchFilter extends AbstractFilter
{
public function getDescription(string $resourceClass): array
{
return [
'search' => [
'property' => null,
'type' => 'string',
'required' => false,
'description' => 'Recherche dans le nom, l\'email et les numéros de téléphone du fournisseur.',
'openapi' => [
'allowEmptyValue' => true,
],
],
];
}
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
if ('search' !== $property || !is_string($value) || '' === trim($value)) {
return;
}
$alias = $queryBuilder->getRootAliases()[0];
$telAlias = $queryNameGenerator->generateJoinAlias('phoneSearch');
$paramName = $queryNameGenerator->generateParameterName('search');
$likePattern = '%'.mb_strtolower(trim($value)).'%';
$em = $queryBuilder->getEntityManager();
$phoneSubQuery = $em->createQueryBuilder()
->select('1')
->from(ConstructeurTelephone::class, $telAlias)
->where(sprintf('%1$s.constructeur = %2$s', $telAlias, $alias))
->andWhere(sprintf('LOWER(%s.numero) LIKE :%s', $telAlias, $paramName))
->getDQL()
;
$queryBuilder
->andWhere(sprintf(
'LOWER(%1$s.name) LIKE :%2$s OR LOWER(%1$s.email) LIKE :%2$s OR EXISTS (%3$s)',
$alias,
$paramName,
$phoneSubQuery,
))
->setParameter($paramName, $likePattern)
;
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Constructeur;
use App\Entity\Constructeur;
use App\Entity\ConstructeurTelephone;
use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool;
@@ -34,7 +35,12 @@ class CreateConstructeurTool
$constructeur = new Constructeur();
$constructeur->setName($name);
$constructeur->setEmail('' !== $email ? $email : null);
$constructeur->setPhone('' !== $phone ? $phone : null);
if ('' !== $phone) {
$telephone = new ConstructeurTelephone();
$telephone->setNumero($phone);
$constructeur->addTelephone($telephone);
}
$this->em->persist($constructeur);
$this->em->flush();
@@ -29,13 +29,23 @@ class GetConstructeurTool
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
}
$telephones = array_map(
static fn ($t): array => ['id' => $t->getId(), 'numero' => $t->getNumero(), 'label' => $t->getLabel()],
$constructeur->getTelephones()->toArray(),
);
$categories = array_values(array_filter(array_map(
static fn ($c): ?string => $c->getName(),
$constructeur->getCategories()->toArray(),
)));
return $this->jsonResponse([
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(),
'phone' => $constructeur->getPhone(),
'createdAt' => $constructeur->getCreatedAt()->format('Y-m-d H:i:s'),
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'),
'id' => $constructeur->getId(),
'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(),
'telephones' => array_values($telephones),
'categories' => $categories,
'createdAt' => $constructeur->getCreatedAt()->format('Y-m-d H:i:s'),
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'),
]);
}
}
@@ -30,7 +30,7 @@ class ListConstructeursTool
;
$qb = $this->constructeurs->createQueryBuilder('c')
->select('c.id', 'c.name', 'c.email', 'c.phone')
->select('c.id', 'c.name', 'c.email')
->orderBy('c.name', 'ASC')
;
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Constructeur;
use App\Entity\ConstructeurTelephone;
use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository;
use Doctrine\ORM\EntityManagerInterface;
@@ -13,7 +14,7 @@ use Symfony\Bundle\SecurityBundle\Security;
#[McpTool(
name: 'update_constructeur',
description: 'Update an existing constructeur. Only provided fields are changed. Requires ROLE_GESTIONNAIRE.',
description: 'Update an existing constructeur. Only provided fields are changed. A non-empty "phone" is added as an additional phone number if not already present (existing numbers are never removed). Requires ROLE_GESTIONNAIRE.',
)]
class UpdateConstructeurTool
{
@@ -45,8 +46,20 @@ class UpdateConstructeurTool
if (null !== $email) {
$constructeur->setEmail($email);
}
if (null !== $phone) {
$constructeur->setPhone($phone);
if (null !== $phone && '' !== $phone) {
$alreadyPresent = false;
foreach ($constructeur->getTelephones() as $existing) {
if ($existing->getNumero() === $phone) {
$alreadyPresent = true;
break;
}
}
if (!$alreadyPresent) {
$telephone = new ConstructeurTelephone();
$telephone->setNumero($phone);
$constructeur->addTelephone($telephone);
}
}
$this->em->flush();
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Machine;
use App\Entity\Composant;
use App\Entity\Constructeur;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
@@ -364,7 +365,7 @@ class MachineStructureTool
'id' => $link->getConstructeur()->getId(),
'name' => $link->getConstructeur()->getName(),
'email' => $link->getConstructeur()->getEmail(),
'phone' => $link->getConstructeur()->getPhone(),
'phone' => $this->constructeurPhone($link->getConstructeur()),
],
'supplierReference' => $link->getSupplierReference(),
];
@@ -373,6 +374,13 @@ class MachineStructureTool
return $items;
}
private function constructeurPhone(Constructeur $constructeur): ?string
{
$first = $constructeur->getTelephones()->first();
return false !== $first ? $first->getNumero() : null;
}
private function normalizeCustomFields(Collection $customFields): array
{
$items = [];
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ConstructeurCategorie;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ConstructeurCategorie>
*/
class ConstructeurCategorieRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ConstructeurCategorie::class);
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ConstructeurTelephone;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ConstructeurTelephone>
*/
class ConstructeurTelephoneRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ConstructeurTelephone::class);
}
}
+8 -3
View File
@@ -7,6 +7,7 @@ namespace App\Service;
use App\Entity\Composant;
use App\Entity\CustomFieldValue;
use App\Entity\Piece;
use Doctrine\ORM\EntityNotFoundException;
class ReferenceAutoGenerator
{
@@ -32,7 +33,7 @@ class ReferenceAutoGenerator
}
}
return preg_replace_callback('/\{(\w+)\}/', static function (array $matches) use ($valueMap): string {
return preg_replace_callback('/\{([^}]+)\}/u', static function (array $matches) use ($valueMap): string {
return $valueMap[$matches[1]] ?? '';
}, $modelType->getReferenceFormula());
}
@@ -48,8 +49,12 @@ class ReferenceAutoGenerator
/** @var CustomFieldValue $cfv */
foreach ($entity->getCustomFieldValues() as $cfv) {
$normalized = mb_strtoupper(trim($cfv->getValue()));
$map[$cfv->getCustomField()->getName()] = $normalized;
try {
$name = $cfv->getCustomField()->getName();
} catch (EntityNotFoundException) {
continue;
}
$map[$name] = mb_strtoupper(trim($cfv->getValue()));
}
return $map;
+39
View File
@@ -226,6 +226,13 @@ class SkeletonStructureService
}
if ($existingField) {
// Propagate rename to the parent ModelType's reference formula and required-fields list
// so existing `{oldName}` placeholders keep resolving after the field is renamed.
$oldName = $existingField->getName();
if ($oldName !== $normalized['name']) {
$this->propagateCustomFieldRename($modelType, $oldName, $normalized['name']);
}
// Update existing field
$existingField->setName($normalized['name']);
$existingField->setType($normalized['type']);
@@ -264,6 +271,38 @@ class SkeletonStructureService
}
}
private function propagateCustomFieldRename(ModelType $modelType, string $oldName, string $newName): void
{
$formula = $modelType->getReferenceFormula();
if (null !== $formula && '' !== $formula) {
$newFormula = preg_replace(
'/\{'.preg_quote($oldName, '/').'\}/',
'{'.$newName.'}',
$formula
);
if (null !== $newFormula && $newFormula !== $formula) {
$modelType->setReferenceFormula($newFormula);
}
}
$required = $modelType->getRequiredFieldsForReference();
if ($required) {
$changed = false;
$newRequired = [];
foreach ($required as $fieldName) {
if ($fieldName === $oldName) {
$newRequired[] = $newName;
$changed = true;
} else {
$newRequired[] = $fieldName;
}
}
if ($changed) {
$modelType->setRequiredFieldsForReference($newRequired);
}
}
}
/**
* Normalize frontend custom field data to a common shape.
*
+34 -1
View File
@@ -12,6 +12,8 @@ use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot;
use App\Entity\Constructeur;
use App\Entity\ConstructeurCategorie;
use App\Entity\ConstructeurTelephone;
use App\Entity\CustomField;
use App\Entity\CustomFieldValue;
use App\Entity\Machine;
@@ -250,7 +252,12 @@ abstract class AbstractApiTestCase extends ApiTestCase
$c = new Constructeur();
$c->setName($name);
$c->setEmail($email);
$c->setPhone($phone);
if (null !== $phone) {
$tel = new ConstructeurTelephone();
$tel->setNumero($phone);
$c->addTelephone($tel);
}
$em = $this->getEntityManager();
$em->persist($c);
@@ -259,6 +266,32 @@ abstract class AbstractApiTestCase extends ApiTestCase
return $c;
}
protected function createConstructeurCategorie(string $name = 'Catégorie Test'): ConstructeurCategorie
{
$categorie = new ConstructeurCategorie();
$categorie->setName($name);
$em = $this->getEntityManager();
$em->persist($categorie);
$em->flush();
return $categorie;
}
protected function createConstructeurTelephone(Constructeur $constructeur, string $numero = '0102030405', ?string $label = null): ConstructeurTelephone
{
$tel = new ConstructeurTelephone();
$tel->setConstructeur($constructeur);
$tel->setNumero($numero);
$tel->setLabel($label);
$em = $this->getEntityManager();
$em->persist($tel);
$em->flush();
return $tel;
}
protected function createMachineConstructeurLink(Machine $machine, Constructeur $constructeur, ?string $supplierReference = null): MachineConstructeurLink
{
$link = new MachineConstructeurLink();
+26 -5
View File
@@ -33,9 +33,9 @@ class ConstructeurTest extends AbstractApiTestCase
$this->assertResponseIsSuccessful();
$this->assertJsonContains([
'name' => 'Siemens',
'email' => 'contact@siemens.com',
'phone' => '+33123456789',
'name' => 'Siemens',
'email' => 'contact@siemens.com',
'telephones' => [['numero' => '+33123456789']],
]);
}
@@ -78,11 +78,32 @@ class ConstructeurTest extends AbstractApiTestCase
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('constructeurs', $c->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['phone' => '+33987654321'],
'json' => ['email' => 'updated@siemens.com'],
]);
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['phone' => '+33987654321']);
$this->assertJsonContains(['email' => 'updated@siemens.com']);
}
public function testPatchCategories(): void
{
$c = $this->createConstructeur('Siemens');
$cat1 = $this->createConstructeurCategorie('Transporteur');
$cat2 = $this->createConstructeurCategorie('Organisme de formation');
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('constructeurs', $c->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['categories' => [
self::iri('constructeur_categories', $cat1->getId()),
self::iri('constructeur_categories', $cat2->getId()),
]],
]);
$this->assertResponseIsSuccessful();
$client->request('GET', self::iri('constructeurs', $c->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['categories' => [['name' => 'Transporteur'], ['name' => 'Organisme de formation']]]);
}
public function testDelete(): void
+82
View File
@@ -134,6 +134,88 @@ class MachineTest extends AbstractApiTestCase
$this->assertResponseStatusCodeSame(422);
}
public function testSameNameOnDifferentSitesIsAllowed(): void
{
$siteA = $this->createSite('Usine A');
$siteB = $this->createSite('Usine B');
$this->createMachine('Pompe', $siteA);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machines', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Pompe',
'site' => self::iri('sites', $siteB->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['name' => 'Pompe']);
}
public function testSameNameOnSameSiteIsRejected(): void
{
$site = $this->createSite('Usine');
$this->createMachine('Pompe', $site);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machines', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Pompe',
'site' => self::iri('sites', $site->getId()),
],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
],
]);
}
public function testRenameToExistingNameOnSameSiteIsRejected(): void
{
$site = $this->createSite('Usine');
$this->createMachine('Pompe', $site);
$other = $this->createMachine('Moteur', $site);
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('machines', $other->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'Pompe'],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
],
]);
}
public function testMoveToSiteWhereNameExistsIsRejected(): void
{
$siteA = $this->createSite('Usine A');
$siteB = $this->createSite('Usine B');
$this->createMachine('Pompe', $siteB);
$machine = $this->createMachine('Pompe', $siteA);
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('machines', $machine->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => self::iri('sites', $siteB->getId())],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
],
]);
}
public function testGetStructureEndpoint(): void
{
$machine = $this->createMachine('Machine structure');
@@ -145,6 +145,69 @@ class ReferenceAutoGeneratorTest extends AbstractApiTestCase
self::assertSame('U507', $result);
}
public function testGenerateWithAccentedFieldName(): void
{
$mt = $this->createModelType('Palier', 'PAL-ACCENT', ModelCategory::PIECE);
$mt->setReferenceFormula('PA-{Diamètre}-33');
$mt->setRequiredFieldsForReference(['Diamètre']);
$em = $this->getEntityManager();
$em->flush();
$cf = $this->createCustomField('Diamètre', 'number', typePiece: $mt);
$piece = $this->createPiece('Palier 70', null, $mt);
$this->createCustomFieldValue($cf, '70', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('PA-70-33', $result);
}
public function testGenerateWithNumberTypeField(): void
{
$mt = $this->createModelType('NumberField', 'NUM-001', ModelCategory::PIECE);
$mt->setReferenceFormula('R-{taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$cf = $this->createCustomField('taille', 'number', typePiece: $mt);
$piece = $this->createPiece('Piece Number', null, $mt);
$this->createCustomFieldValue($cf, '42', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('R-42', $result);
}
public function testGenerateWithDecimalNumberField(): void
{
$mt = $this->createModelType('NumberDec', 'NUM-002', ModelCategory::PIECE);
$mt->setReferenceFormula('R-{taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$cf = $this->createCustomField('taille', 'number', typePiece: $mt);
$piece = $this->createPiece('Piece Dec', null, $mt);
$this->createCustomFieldValue($cf, '12.5', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('R-12.5', $result);
}
public function testGenerateWithSpaceInFormula(): void
{
$mt = $this->createModelType('Palier2', 'PAL-002', ModelCategory::PIECE);
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Enum\ModelCategory;
use App\Service\SkeletonStructureService;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class SkeletonStructureServiceTest extends AbstractApiTestCase
{
public function testRenameCustomFieldPropagatesToReferenceFormulaAndRequiredFields(): void
{
$mt = $this->createModelType('Roulement', 'ROUL-RENAME', ModelCategory::PIECE);
$mt->setReferenceFormula('{material}-{size}');
$mt->setRequiredFieldsForReference(['material', 'size']);
$em = $this->getEntityManager();
$em->flush();
$cfMaterial = $this->createCustomField('material', 'text', typePiece: $mt, orderIndex: 0);
$cfSize = $this->createCustomField('size', 'text', typePiece: $mt, orderIndex: 1);
/** @var SkeletonStructureService $service */
$service = static::getContainer()->get(SkeletonStructureService::class);
// Same fields, but `material` is renamed to `materiau` (matched by customFieldId)
$service->updateSkeletonRequirements($mt, [
'customFields' => [
['customFieldId' => $cfMaterial->getId(), 'name' => 'materiau', 'type' => 'text', 'orderIndex' => 0],
['customFieldId' => $cfSize->getId(), 'name' => 'size', 'type' => 'text', 'orderIndex' => 1],
],
]);
$em->flush();
$em->refresh($mt);
$em->refresh($cfMaterial);
self::assertSame('materiau', $cfMaterial->getName());
self::assertSame('{materiau}-{size}', $mt->getReferenceFormula());
self::assertSame(['materiau', 'size'], $mt->getRequiredFieldsForReference());
}
public function testRenameLeavesFormulaUnchangedWhenFieldNotInFormula(): void
{
$mt = $this->createModelType('Roulement2', 'ROUL-RENAME2', ModelCategory::PIECE);
$mt->setReferenceFormula('{material}');
$mt->setRequiredFieldsForReference(['material']);
$em = $this->getEntityManager();
$em->flush();
$cfMaterial = $this->createCustomField('material', 'text', typePiece: $mt, orderIndex: 0);
$cfUnused = $this->createCustomField('unused', 'text', typePiece: $mt, orderIndex: 1);
/** @var SkeletonStructureService $service */
$service = static::getContainer()->get(SkeletonStructureService::class);
$service->updateSkeletonRequirements($mt, [
'customFields' => [
['customFieldId' => $cfMaterial->getId(), 'name' => 'material', 'type' => 'text', 'orderIndex' => 0],
['customFieldId' => $cfUnused->getId(), 'name' => 'renamed', 'type' => 'text', 'orderIndex' => 1],
],
]);
$em->flush();
$em->refresh($mt);
self::assertSame('{material}', $mt->getReferenceFormula());
self::assertSame(['material'], $mt->getRequiredFieldsForReference());
}
}