Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90ad851804 | |||
| c3ad3b68a2 | |||
| 494298f981 | |||
| b775718df6 | |||
| c02f999a32 | |||
| e05ba6a97c | |||
| 012d552ddc |
@@ -1,9 +1,7 @@
|
|||||||
# CLAUDE.md — Inventory Project
|
# CLAUDE.md — Inventory Project
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
Application de gestion d'inventaire industriel (machines, pièces, composants, produits).
|
Application de gestion d'inventaire industriel (machines, pièces, composants, produits).
|
||||||
Mono-repo : backend Symfony et frontend Nuxt (`frontend/`) dans le **même dépôt git** (plus de submodule). Un seul commit/push couvre backend + frontend.
|
**Monorepo** : backend Symfony + frontend Nuxt (`frontend/`) dans le **même dépôt git** (plus de submodule). Un seul commit/push couvre backend + frontend.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -15,267 +13,134 @@ Mono-repo : backend Symfony et frontend Nuxt (`frontend/`) dans le **même dép
|
|||||||
| Frontend | Nuxt (SPA, SSR off) | 4 |
|
| Frontend | Nuxt (SPA, SSR off) | 4 |
|
||||||
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 |
|
| UI | Vue 3 Composition API + TypeScript | 3.5 / 5.7 |
|
||||||
| CSS | TailwindCSS 4 + DaisyUI 5 | |
|
| CSS | TailwindCSS 4 + DaisyUI 5 | |
|
||||||
| Auth | Session-based (cookies, pas JWT) | |
|
| Auth | Session-based (cookies, **pas JWT**) | |
|
||||||
| Containers | Docker Compose | |
|
| Containers | Docker Compose | |
|
||||||
|
|
||||||
## Glossaire Métier
|
## Documentation détaillée (lire à la demande, ne pas dupliquer ici)
|
||||||
Voir `docs/GLOSSAIRE_METIER.md` — glossaire complet du domaine métier (concepts, workflows utilisateur, correspondance métier↔code). À consulter pour comprendre le "pourquoi" derrière le code.
|
|
||||||
|
Vu la complexité du projet, le détail vit dans `docs/` — y aller plutôt que de deviner :
|
||||||
|
|
||||||
|
- **`docs/FONCTIONNEMENT.md`** — le métier : à quoi sert l'app, entités, ModelType/skeleton, cycle de vie, rôles, fonctionnalités clés.
|
||||||
|
- **`docs/GLOSSAIRE_METIER.md`** — glossaire complet, correspondance métier ↔ code (le « pourquoi »).
|
||||||
|
- **`docs/BACKEND.md`** — catalogue backend complet : toutes les entités, **tous les controllers** (routes), audit, services, migrations, auth, rôles.
|
||||||
|
- **`docs/FRONTEND.md`** — catalogue frontend : composables, composants, useApi, IRIs, content-types, auth, style.
|
||||||
|
- **`docs/REVIEW_ARCHITECTURE.md`** — top 10 des sources de complexité et effets de bord (God controllers, canaux cachés, doubles flush…). **À consulter avant tout refacto.**
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
Inventory/ # Backend Symfony (repo principal)
|
Inventory/ # Backend Symfony (repo principal)
|
||||||
├── src/Entity/ # Entités Doctrine (annotations PHP 8 attributes)
|
├── src/Entity/ (+ Trait/) # Entités Doctrine (attributs PHP 8), CuidEntityTrait
|
||||||
│ └── Trait/ # CuidEntityTrait (génération d'ID CUID)
|
├── src/Controller/ # Controllers custom (session, comments, audit, structure…)
|
||||||
├── src/Controller/ # Controllers custom (session, comments, audit…)
|
├── src/EventSubscriber/ # Audit (onFlush) + sync/contraintes
|
||||||
├── src/EventSubscriber/ # Audit subscribers (onFlush)
|
├── src/Service/ (+ Sync/) # Services métier (sync, conversion, storage, versions…)
|
||||||
├── src/Service/ # Services métier (sync, conversion, storage…)
|
├── src/Enum/ src/DTO/ src/Filter/ src/Command/
|
||||||
├── src/Enum/ # Enums PHP (DocumentType, ModelCategory)
|
├── config/ migrations/ docker/ scripts/ fixtures/ tests/
|
||||||
├── src/DTO/ # Data Transfer Objects (sync workflow)
|
├── makefile VERSION # VERSION = source unique de version (semver)
|
||||||
├── src/Filter/ # Filtres API Platform custom
|
└── frontend/ # ← Frontend Nuxt (MÊME repo, pas un submodule)
|
||||||
├── src/Command/ # Commandes Symfony CLI (compress-pdf, create-profile…)
|
└── app/{pages,components,composables,shared,middleware,services}/
|
||||||
├── config/ # Config Symfony
|
|
||||||
├── migrations/ # Migrations Doctrine (raw SQL PostgreSQL)
|
|
||||||
├── docker/ # Dockerfile + .env.docker
|
|
||||||
├── scripts/ # release.sh, normalize-dump.py
|
|
||||||
├── fixtures/ # SQL fixtures
|
|
||||||
├── tests/ # PHPUnit
|
|
||||||
├── pre-commit, commit-msg # Git hooks
|
|
||||||
├── makefile # Commandes Docker/dev
|
|
||||||
├── VERSION # Source unique de version (semver)
|
|
||||||
├── frontend/ # ← Frontend Nuxt (DANS le même repo, pas un submodule)
|
|
||||||
│ ├── app/pages/ # Pages Nuxt (file-based routing)
|
|
||||||
│ ├── app/components/ # Composants Vue (auto-imported)
|
|
||||||
│ ├── app/composables/ # Composables Vue
|
|
||||||
│ ├── app/shared/ # Types, utils, validation
|
|
||||||
│ ├── app/middleware/ # Auth middleware global
|
|
||||||
│ └── app/services/ # Service layer (wrappers useApi)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Commands
|
## Key Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Docker
|
# Docker
|
||||||
make start # Démarrer les containers
|
make start / make stop # Démarrer / arrêter les containers
|
||||||
make stop # Arrêter
|
make shell # Shell interactif (nécessite un TTY)
|
||||||
make shell # Shell interactif (nécessite un TTY)
|
make install # Install complet (composer + npm + build)
|
||||||
make install # Install complet (composer + npm + build)
|
|
||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
make test # PHPUnit (tous les tests)
|
make test # PHPUnit (tous)
|
||||||
make test FILES=tests/Api/Entity/MachineTest.php # Un test spécifique
|
make test FILES=tests/Api/Entity/MachineTest.php # Un test
|
||||||
make php-cs-fixer-allow-risky # Linter PHP (cs-fixer)
|
make test-setup # Créer/MAJ le schéma de test
|
||||||
|
make php-cs-fixer-allow-risky # Linter PHP
|
||||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate
|
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate
|
||||||
|
|
||||||
# Frontend (dans frontend/)
|
# Frontend (dans frontend/)
|
||||||
npm run dev # Dev server (port 3001)
|
npm run dev # Dev server (port 3001)
|
||||||
npm run build # Build production
|
npm run lint:fix # ESLint fix
|
||||||
npm run lint:fix # ESLint fix
|
npx nuxi typecheck # TypeScript check (0 erreur attendu)
|
||||||
npx nuxi typecheck # TypeScript check (0 errors attendu)
|
|
||||||
|
|
||||||
# Database / Fixtures
|
# Database / Fixtures
|
||||||
make db-reset # Reset database (drop + recreate schema)
|
make db-reset # Reset DB (drop + recreate schema)
|
||||||
make fixtures-dump # Dump la DB vers fixtures/data.sql
|
make fixtures-reset # Reset DB + recharger fixtures SQL
|
||||||
make fixtures-load # Charger les fixtures SQL (désactive FK)
|
make import-data # Importer les dumps SQL normalisés
|
||||||
make fixtures-reset # Reset DB + recharger fixtures
|
make cache-clear
|
||||||
make import-data # Importer les dumps SQL normalisés
|
|
||||||
make cache-clear # Clear cache Symfony
|
|
||||||
|
|
||||||
# Import fournisseurs (customer.json → Constructeur + ConstructeurCategorie + ConstructeurTelephone)
|
# Import fournisseurs (non destructif : find-or-create par nom normalisé)
|
||||||
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs # dry-run (par défaut)
|
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs # dry-run
|
||||||
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs --force # applique
|
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs --force # applique
|
||||||
# Non destructif : find-or-create par nom normalisé, ne change jamais un ID existant, n'ajoute que les téléphones/catégories manquants
|
|
||||||
|
|
||||||
# Release
|
# Release
|
||||||
./scripts/release.sh patch # Bump patch version (ou minor/major)
|
./scripts/release.sh patch # Bump version (patch/minor/major)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Git Conventions
|
## Git Conventions
|
||||||
|
|
||||||
### Branches
|
- **Branches** : `master` (prod), `develop` (cible des PR), `feat/* fix/* refactor/*`.
|
||||||
- `master` — production
|
- **Commit** (enforced par hook) : `<type>(<scope>) : <message>` — **espace obligatoire autour du `:`**. Types : `build chore ci docs feat fix perf refactor revert style test wip`.
|
||||||
- `develop` — branche principale de dev (cible des PR)
|
- Ex : `feat(auth) : add login page`, `fix(machines) : prevent null crash`
|
||||||
- `feat/xxx`, `fix/xxx`, `refactor/xxx` — branches de travail
|
- **Pre-commit hook** : php-cs-fixer + PHPUnit (bloque si échec).
|
||||||
|
- **Workflow commit** : backend + frontend = **un seul commit/push** depuis la racine (pas de submodule). Le hook étant lent, committer avec `git commit --no-verify`. Push rejeté → `git pull --rebase` puis `git push`.
|
||||||
|
- **Sync master ↔ develop** : `git checkout master && git merge develop && git push` puis revenir sur `develop`.
|
||||||
|
|
||||||
### Commit Message Format (enforced by hook)
|
## Pièges & patterns non-évidents
|
||||||
```
|
|
||||||
<type>(<scope optionnel>) : <message>
|
|
||||||
```
|
|
||||||
**Espace obligatoire autour du `:`**. Types autorisés (minuscules) :
|
|
||||||
`build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`, `wip`
|
|
||||||
|
|
||||||
Exemples :
|
> Le catalogue complet est dans `docs/BACKEND.md` / `docs/FRONTEND.md`. Ci-dessous **uniquement** ce qui n'est pas évident en lisant le code.
|
||||||
- `feat(auth) : add login page`
|
|
||||||
- `fix(machines) : prevent null crash on skeleton creation`
|
|
||||||
|
|
||||||
### Pre-commit Hook
|
### Backend
|
||||||
1. php-cs-fixer sur les fichiers PHP stagés
|
- **IDs CUID** : strings `'cl' + bin2hex(random_bytes(12))`, **pas** d'auto-increment.
|
||||||
2. PHPUnit — bloque le commit si tests échouent
|
- **Lifecycle** : `#[ORM\HasLifecycleCallbacks]` + `PrePersist`/`PreUpdate` pour `createdAt`/`updatedAt`.
|
||||||
|
- **Sécurité** : `security: "is_granted('ROLE_...')"` sur chaque opération API Platform. Hiérarchie : `ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER`.
|
||||||
### Workflow commit (backend + frontend dans le même repo)
|
- **Audit** : subscribers Doctrine `onFlush` (diff + snapshot complet).
|
||||||
Le frontend n'est **pas** un submodule : `frontend/` est versionné dans le dépôt principal. Un changement backend et/ou frontend se commite et se push en **une seule fois** depuis la racine `Inventory/`. Pas de double commit ni de pointeur de submodule à gérer.
|
- **Migrations** : raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` (idempotence).
|
||||||
- Commit avec `git commit --no-verify` (le pre-commit hook php-cs-fixer + PHPUnit est trop lent).
|
- **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`.
|
||||||
- Si le push est rejeté (distant en avance), faire `git pull --rebase` puis `git push`.
|
- **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.
|
||||||
## Architecture Backend
|
- **`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).
|
||||||
|
|
||||||
### Entités Principales
|
|
||||||
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `ConstructeurCategorie`, `ConstructeurTelephone`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
|
|
||||||
|
|
||||||
> **Constructeur (Fournisseur)** : possède `name`, `email`, une collection `telephones` (1-N → `ConstructeurTelephone`, cascade/orphanRemoval) et `categories` (M2M → `ConstructeurCategorie`, table `constructeur_categories`). Sérialisation API Platform via les groupes `constructeur:read` / `constructeur:write` (téléphones & catégories embarqués). ⚠️ L'adder M2M s'appelle `addCategory()`/`removeCategory()` (l'inflector singularise `categories` → `category`), pas `addCategorie`. `ConstructeurCategorie` et `ConstructeurTelephone` sont aussi des `ApiResource` à part entière (`/api/constructeur_categories`, `/api/constructeur_telephones`).
|
|
||||||
|
|
||||||
#### Entités de normalisation (slots & skeleton requirements)
|
|
||||||
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
|
|
||||||
- **Slots composant** (données réelles d'un composant) : `ComposantPieceSlot`, `ComposantSubcomponentSlot`, `ComposantProductSlot`
|
|
||||||
- **Slots pièce** (données réelles d'une pièce) : `PieceProductSlot`
|
|
||||||
- **Skeleton Requirements** (définitions du ModelType) : `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement`
|
|
||||||
|
|
||||||
### Patterns
|
|
||||||
- **IDs** : CUID-like strings (`'cl' + bin2hex(random_bytes(12))`), pas d'auto-increment
|
|
||||||
- **ORM** : Attributs PHP 8 (`#[ORM\Column(...)]`, `#[Groups([...])]`)
|
|
||||||
- **Lifecycle** : `#[ORM\HasLifecycleCallbacks]` avec `PrePersist`/`PreUpdate` pour `createdAt`/`updatedAt`
|
|
||||||
- **Sécurité** : `security: "is_granted('ROLE_...')"` sur chaque opération API Platform
|
|
||||||
- **Audit** : Subscribers Doctrine `onFlush` capturent diff + snapshot complet
|
|
||||||
- **Migrations** : Raw SQL PostgreSQL avec `IF NOT EXISTS`/`IF EXISTS` pour idempotence
|
|
||||||
|
|
||||||
### Custom Controllers (pas API Platform)
|
|
||||||
- `MachineStructureController` — `/api/machines/{id}/structure` (GET/PATCH), `/api/machines/{id}/clone` (POST) : hiérarchie complète machine avec normalisation JSON manuelle. Source principale de données pour la page détail machine.
|
|
||||||
- `MachineCustomFieldsController` — `/api/machines/{id}/add-custom-fields` (POST) : initialise les CustomFieldValue manquants pour une machine.
|
|
||||||
- `CustomFieldValueController` — `/api/custom-fields/values/*` : CRUD + upsert pour les valeurs de champs perso.
|
|
||||||
- `ComposantPieceSlotController` — `/api/composant-piece-slots/{id}` (PATCH) : mise à jour des slots pièce d'un composant.
|
|
||||||
- `ComposantProductSlotController` — `/api/composant-product-slots/{id}` (PATCH) : mise à jour des slots produit d'un composant.
|
|
||||||
- `ComposantSubcomponentSlotController` — `/api/composant-subcomponent-slots/{id}` (PATCH) : mise à jour des slots sous-composant d'un composant.
|
|
||||||
- `SessionProfileController` — `/api/session/profile` (GET/POST/DELETE) : auth session (login/logout/current user).
|
|
||||||
- `SessionProfilesController` — `/api/session/profiles` (GET) : liste des profils disponibles pour la session.
|
|
||||||
- `AdminProfileController` — `/api/admin/profiles` : CRUD profils, gestion rôles et mots de passe (ROLE_ADMIN).
|
|
||||||
- `CommentController` — `/api/comments` : création, résolution, compteur non-résolus.
|
|
||||||
- `ActivityLogController` — `/api/activity-logs` (GET) : journal d'activité global.
|
|
||||||
- `EntityHistoryController` — `/api/{entity}/{id}/history` (GET) : historique audit par entité (machines, pièces, composants, produits).
|
|
||||||
- `DocumentQueryController` — `/api/documents/{entity}/{id}` (GET) : documents par site/machine/composant/pièce/produit.
|
|
||||||
- `DocumentServeController` — `/api/documents/{id}/file|download` (GET) : servir/télécharger fichiers.
|
|
||||||
- `ModelTypeConversionController` — `/api/model_types/{id}/conversion-check|convert` : vérification et conversion de ModelType.
|
|
||||||
- `ModelTypeSyncController` — `/api/model_types/{id}/sync-preview|sync-confirm` (POST) : prévisualisation et application de sync ModelType→Composants.
|
|
||||||
- `EntityVersionController` — `/api/{entity}/{id}/versions` (GET), `/api/{entity}/{id}/versions/{version}/restore` (POST) : historique de versions numérotées et restauration.
|
|
||||||
- `HealthCheckController` — `/api/health` (GET) : health check.
|
|
||||||
|
|
||||||
### Custom Fields — Architecture
|
|
||||||
- **Composants/Pièces/Produits** : définitions dans les entités `SkeletonPieceRequirement`, `SkeletonProductRequirement`, `SkeletonSubcomponentRequirement` du ModelType (anciennement JSON `structure`, normalisé en tables relationnelles). Les custom fields de ces entités sont définis dans `customFields` JSON sur chaque Skeleton*Requirement.
|
|
||||||
- **Machines** : définitions = entités `CustomField` liées directement via `machineId` FK (pas de ModelType)
|
|
||||||
- Les deux partagent la même entité `CustomFieldValue` pour stocker les valeurs
|
|
||||||
|
|
||||||
### Enums (`src/Enum/`)
|
|
||||||
- `DocumentType` — types de documents (photo, schéma, facture, etc.)
|
|
||||||
- `ModelCategory` — catégories de ModelType
|
|
||||||
|
|
||||||
### Services (`src/Service/`)
|
|
||||||
- `ModelTypeSyncService` — synchronise les skeleton requirements d'un ModelType vers les composants existants
|
|
||||||
- `ModelTypeCategoryConversionService` — conversion de catégorie d'un ModelType
|
|
||||||
- `SkeletonStructureService` — gestion de la structure skeleton (requirements)
|
|
||||||
- `DocumentStorageService` — stockage et gestion des fichiers documents
|
|
||||||
- `PdfCompressorService` — compression des PDFs uploadés
|
|
||||||
- `EntityVersionService` — gestion des versions numérotées (snapshot, restore) pour machines, pièces, composants, produits
|
|
||||||
- `ReferenceAutoGenerator` — génération automatique de références pour pièces et composants à partir de formules ModelType
|
|
||||||
- `src/Service/Sync/` — stratégies de sync par type de slot (tagged `app.sync_strategy`)
|
|
||||||
|
|
||||||
### DTOs (`src/DTO/`)
|
|
||||||
- `SyncConfirmation`, `SyncPreviewResult`, `SyncExecutionResult` — objets de transfert pour le workflow de sync ModelType
|
|
||||||
|
|
||||||
### Filters (`src/Filter/`)
|
|
||||||
- `MultiSearchFilter` — filtre API Platform pour recherche OR sur plusieurs champs (ex: name + reference)
|
|
||||||
|
|
||||||
### EventSubscribers notables (non-audit)
|
|
||||||
- `PieceProductSyncSubscriber` — sync automatique des PieceProductSlots
|
|
||||||
- `UniqueConstraintSubscriber` — traduit les erreurs de contrainte unique PG en messages utilisateur lisibles
|
|
||||||
- `ReferenceAutoSubscriber` — recalcule les références auto des pièces/composants quand les CustomFieldValues changent (onFlush)
|
|
||||||
|
|
||||||
### Rôles (hiérarchie)
|
|
||||||
```
|
|
||||||
ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
|
|
||||||
```
|
|
||||||
|
|
||||||
### PostgreSQL — ATTENTION
|
### PostgreSQL — ATTENTION
|
||||||
- Les noms de colonnes sont **TOUJOURS EN MINUSCULES** dans PG
|
- Noms de colonnes **TOUJOURS EN MINUSCULES** en PG. Doctrine camelCase (`typePieceId`) → PG `typepieceid`. Le **SQL brut doit être lowercase**.
|
||||||
- Doctrine utilise camelCase (`typePieceId`) mais PG stocke `typepieceid`
|
- Tables de jointure M2M : colonnes `a` et `b` (ex : `_piececonstructeurs`).
|
||||||
- Le SQL brut doit utiliser les noms lowercase
|
|
||||||
- Tables de jointure many-to-many : colonnes `a` et `b` (ex: `_piececonstructeurs`)
|
|
||||||
|
|
||||||
## Architecture Frontend
|
### Frontend
|
||||||
|
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`.
|
||||||
### Patterns
|
- **Communication composants** : Props + Events uniquement (**pas** de provide/inject).
|
||||||
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
|
- **API** : `useApi.ts` wrappe fetch avec `credentials: 'include'`. ⚠️ `useApi()` **préfixe déjà** `/api` → appeler **sans** `/api` au début. Ex : `api.get('/custom-fields/names')` **et PAS** `'/api/custom-fields/names'` (sinon 404 sur `/api/api/...`).
|
||||||
- **Communication composants** : Props + Events uniquement (pas de provide/inject)
|
- **Content-Type** : `application/ld+json` (POST/PUT), `application/merge-patch+json` (PATCH).
|
||||||
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session
|
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`. Permissions : `usePermissions.ts` (miroir de la hiérarchie backend).
|
||||||
- **⚠️ Préfixe `/api`** : `useApi()` **prepend déjà** `apiBaseUrl` (= `/api` par défaut, cf. `nuxt.config.ts`). Les appels doivent donc utiliser des chemins **sans** `/api` au début. Ex : `api.get('/custom-fields/names')` et **PAS** `api.get('/api/custom-fields/names')` (sinon 404 sur `/api/api/...`).
|
- **Classes DaisyUI** : `input input-bordered input-sm md:input-md` (idem textarea/select/btn, `btn-primary`).
|
||||||
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
|
|
||||||
- **Auth** : `useProfileSession` + middleware global `profile.global.ts`
|
|
||||||
- **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client
|
|
||||||
- **Auto-imports** : Nuxt auto-importe composants (`components/`) et composables (`composables/`)
|
|
||||||
|
|
||||||
### DaisyUI Classes
|
|
||||||
- Input : `input input-bordered input-sm md:input-md`
|
|
||||||
- Textarea : `textarea textarea-bordered textarea-sm md:textarea-md`
|
|
||||||
- Select : `select select-bordered select-sm md:select-md`
|
|
||||||
- Button : `btn btn-sm md:btn-md btn-primary`
|
|
||||||
|
|
||||||
## Règles Importantes
|
## Règles Importantes
|
||||||
|
|
||||||
### CLAUDE.md — Maintenance obligatoire
|
### Avant de modifier du code
|
||||||
- **Toujours consulter** ce fichier en début de conversation pour respecter les conventions
|
1. **Lire le fichier** avant de l'éditer.
|
||||||
- **Mettre à jour** ce fichier quand une nouvelle convention, pattern ou décision architecturale est établie
|
2. **Reproduire le pattern existant** (noms, indentation, structure).
|
||||||
- **Utiliser comme source de vérité** pour les commandes, patterns et règles du projet
|
3. **Vérifier backend ET frontend** — un changement peut impacter les deux (même repo).
|
||||||
|
|
||||||
### Toujours faire AVANT de modifier du code
|
|
||||||
1. **Lire le fichier** avant de l'éditer — ne jamais proposer de changements sur du code non lu
|
|
||||||
2. **Comprendre le pattern existant** — reproduire le style du fichier (noms, indentation, structure)
|
|
||||||
3. **Vérifier backend ET frontend** — un changement peut impacter les deux (même repo)
|
|
||||||
|
|
||||||
### Après chaque modification
|
### Après chaque modification
|
||||||
1. Backend PHP : `make php-cs-fixer-allow-risky`
|
1. Backend PHP : `make php-cs-fixer-allow-risky`
|
||||||
2. Frontend : `npm run lint:fix` puis `npx nuxi typecheck` si fichiers TS modifiés
|
2. Frontend TS : `npm run lint:fix` puis `npx nuxi typecheck`
|
||||||
|
|
||||||
### Ne jamais faire
|
### Ne jamais faire
|
||||||
- Ajouter des features non demandées, du code mort, ou des abstractions prématurées
|
- Features non demandées, code mort, abstractions prématurées
|
||||||
- Utiliser `provide/inject` — le codebase utilise Props + Events
|
- `provide/inject` (le code utilise Props + Events) · JWT/tokens (auth session-based)
|
||||||
- Utiliser JWT/tokens — l'auth est session-based
|
- SQL en camelCase (PG = lowercase)
|
||||||
- Écrire du SQL avec des noms camelCase — PostgreSQL = lowercase
|
- Committer sans demande explicite · force push sans confirmation · modifier la config git
|
||||||
- Committer sans que l'utilisateur le demande explicitement
|
|
||||||
- Force push sans confirmation explicite
|
|
||||||
- Modifier la config git
|
|
||||||
|
|
||||||
### Synchronisation master ↔ develop
|
### Maintenir ce fichier
|
||||||
Un seul repo (backend + frontend). Quand `master` et `develop` divergent :
|
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/`.
|
||||||
`git checkout master && git merge develop && git push` (puis revenir sur `develop`).
|
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
### Stack de test
|
- **PHPUnit 12** + **API Platform Test** (`ApiTestCase`), env `test`, même PG.
|
||||||
- **PHPUnit 12** + **API Platform Test** (`ApiTestCase`)
|
- **DAMA DoctrineTestBundle** : chaque test wrappé en transaction + rollback auto → **ne PAS** faire de TRUNCATE/cleanup en `tearDown`.
|
||||||
- **DAMA DoctrineTestBundle** — wrappe chaque test dans une transaction avec rollback automatique (pas de TRUNCATE)
|
- Hériter de `AbstractApiTestCase` (helpers auth + factories `create*()`).
|
||||||
- Base de test : même PG, env `test`
|
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`.
|
||||||
|
|
||||||
### Commandes
|
|
||||||
Voir section "Key Commands". Commande additionnelle :
|
|
||||||
```bash
|
|
||||||
make test-setup # Créer/mettre à jour le schéma test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pattern de test
|
|
||||||
- Hériter de `AbstractApiTestCase` (helpers auth + factories)
|
|
||||||
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
|
|
||||||
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`, `createPieceProductSlot()`, `createConstructeurCategorie()`, `createConstructeurTelephone()`
|
|
||||||
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
|
|
||||||
|
|
||||||
## URLs Locales
|
## URLs Locales
|
||||||
- API Symfony : `http://localhost:8081/api`
|
- API Symfony : `http://localhost:8081/api` · Nuxt dev : `http://localhost:3001`
|
||||||
- Nuxt dev : `http://localhost:3001`
|
- Adminer : `http://localhost:5050` · PG direct : `localhost:5433` (user/pass `root`, db `inventory`)
|
||||||
- Adminer (PG) : `http://localhost:5050`
|
|
||||||
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
|
|
||||||
|
|
||||||
## Delegation Codex
|
## Délégation Codex
|
||||||
|
Pour les tâches mécaniques (tests, boilerplate, renommages, refacto répétitif), déléguer à Codex via le plugin `codex` (junior rapide/pas cher). Garder Claude pour la réflexion, l'architecture et la vérification (senior). Meilleur ratio qualité/crédits.
|
||||||
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
|
|
||||||
|
|
||||||
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
|
|
||||||
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
|
|
||||||
|
|
||||||
C'est le meilleur ratio qualite/credits.
|
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '1.9.46'
|
app.version: '1.9.49'
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<!-- First crumb (always visible) -->
|
<!-- First crumb (always visible) -->
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink :to="crumbs[0].path" class="text-base-content/60 hover:text-primary transition-colors">
|
<NuxtLink :to="crumbs[0].to" class="text-base-content/60 hover:text-primary transition-colors">
|
||||||
{{ crumbs[0].label }}
|
{{ crumbs[0].label }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
:key="i"
|
:key="i"
|
||||||
class="hidden sm:list-item"
|
class="hidden sm:list-item"
|
||||||
>
|
>
|
||||||
<NuxtLink :to="crumb.path" class="text-base-content/60 hover:text-primary transition-colors">
|
<NuxtLink :to="crumb.to" class="text-base-content/60 hover:text-primary transition-colors">
|
||||||
{{ crumb.label }}
|
{{ crumb.label }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
@@ -32,15 +32,40 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
import { useListQueryMemory } from '~/composables/useListQueryMemory'
|
||||||
|
|
||||||
interface Crumb {
|
interface Crumb {
|
||||||
label: string
|
label: string
|
||||||
path: string
|
to: RouteLocationRaw
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { remember, recall } = useListQueryMemory()
|
||||||
|
|
||||||
|
// Routes-listes dont la recherche / tri / pagination doit survivre à une
|
||||||
|
// navigation par fil d'Ariane ou menu (qui ne passe pas par l'historique).
|
||||||
|
const LIST_PATHS = ['/machines', '/catalogues/composants', '/catalogues/pieces', '/catalogues/produits']
|
||||||
|
|
||||||
|
// On enregistre la query courante dès qu'on est sur une route-liste (et à chaque
|
||||||
|
// changement de recherche/tri/pagination, qui modifie fullPath).
|
||||||
|
watch(
|
||||||
|
() => route.fullPath,
|
||||||
|
() => {
|
||||||
|
if (LIST_PATHS.includes(route.path)) remember(route.path, route.query)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cible d'un crumb pointant vers une liste : on réinjecte la dernière query
|
||||||
|
// mémorisée pour restaurer l'état, sinon chemin nu (liste neuve).
|
||||||
|
const listTo = (path: string): RouteLocationRaw => {
|
||||||
|
const query = recall(path)
|
||||||
|
return query && Object.keys(query).length > 0 ? { path, query } : path
|
||||||
|
}
|
||||||
|
|
||||||
const crumbs = computed<Crumb[]>(() => {
|
const crumbs = computed<Crumb[]>(() => {
|
||||||
const result: Crumb[] = [{ label: 'Accueil', path: '/' }]
|
const result: Crumb[] = [{ label: 'Accueil', to: '/' }]
|
||||||
const path = route.path
|
const path = route.path
|
||||||
|
|
||||||
// Home page — no breadcrumb
|
// Home page — no breadcrumb
|
||||||
@@ -48,88 +73,88 @@ const crumbs = computed<Crumb[]>(() => {
|
|||||||
|
|
||||||
// Machine context from query param (when navigating from a machine detail page)
|
// Machine context from query param (when navigating from a machine detail page)
|
||||||
if (route.query.from === 'machine' && route.query.machineId) {
|
if (route.query.from === 'machine' && route.query.machineId) {
|
||||||
result.push({ label: 'Parc machines', path: '/machines' })
|
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||||
result.push({ label: 'Machine', path: `/machine/${route.query.machineId}` })
|
result.push({ label: 'Machine', to: `/machine/${route.query.machineId}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Machines
|
// Machines
|
||||||
if (path === '/machines') {
|
if (path === '/machines') {
|
||||||
result.push({ label: 'Parc machines', path: '/machines' })
|
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||||
} else if (path.startsWith('/machine/') && !route.query.from) {
|
} else if (path.startsWith('/machine/') && !route.query.from) {
|
||||||
result.push({ label: 'Parc machines', path: '/machines' })
|
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||||
result.push({ label: 'Machine', path })
|
result.push({ label: 'Machine', to: path })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catalogs
|
// Catalogs
|
||||||
else if (path.startsWith('/catalogues/composants')) {
|
else if (path.startsWith('/catalogues/composants')) {
|
||||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||||
} else if (path.startsWith('/catalogues/pieces')) {
|
} else if (path.startsWith('/catalogues/pieces')) {
|
||||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||||
} else if (path.startsWith('/catalogues/produits')) {
|
} else if (path.startsWith('/catalogues/produits')) {
|
||||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity detail pages (when NOT from machine context)
|
// Entity detail pages (when NOT from machine context)
|
||||||
else if (path.startsWith('/component/') && !route.query.from) {
|
else if (path.startsWith('/component/') && !route.query.from) {
|
||||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||||
result.push({ label: 'Composant', path })
|
result.push({ label: 'Composant', to: path })
|
||||||
} else if (path.startsWith('/piece/') && !route.query.from) {
|
} else if (path.startsWith('/piece/') && !route.query.from) {
|
||||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||||
result.push({ label: 'Pièce', path })
|
result.push({ label: 'Pièce', to: path })
|
||||||
} else if (path.startsWith('/product/') && !route.query.from) {
|
} else if (path.startsWith('/product/') && !route.query.from) {
|
||||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||||
result.push({ label: 'Produit', path })
|
result.push({ label: 'Produit', to: path })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entity detail pages WITH machine context — add entity as last crumb
|
// Entity detail pages WITH machine context — add entity as last crumb
|
||||||
else if (path.startsWith('/component/') && route.query.from === 'machine') {
|
else if (path.startsWith('/component/') && route.query.from === 'machine') {
|
||||||
result.push({ label: 'Composant', path })
|
result.push({ label: 'Composant', to: path })
|
||||||
} else if (path.startsWith('/piece/') && route.query.from === 'machine') {
|
} else if (path.startsWith('/piece/') && route.query.from === 'machine') {
|
||||||
result.push({ label: 'Pièce', path })
|
result.push({ label: 'Pièce', to: path })
|
||||||
} else if (path.startsWith('/product/') && route.query.from === 'machine') {
|
} else if (path.startsWith('/product/') && route.query.from === 'machine') {
|
||||||
result.push({ label: 'Produit', path })
|
result.push({ label: 'Produit', to: path })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin pages
|
// Admin pages
|
||||||
else if (path.startsWith('/sites')) {
|
else if (path.startsWith('/sites')) {
|
||||||
result.push({ label: 'Sites', path: '/sites' })
|
result.push({ label: 'Sites', to: '/sites' })
|
||||||
} else if (path.startsWith('/constructeurs')) {
|
} else if (path.startsWith('/constructeurs')) {
|
||||||
result.push({ label: 'Fournisseurs', path: '/constructeurs' })
|
result.push({ label: 'Fournisseurs', to: '/constructeurs' })
|
||||||
} else if (path.startsWith('/activity-log')) {
|
} else if (path.startsWith('/activity-log')) {
|
||||||
result.push({ label: 'Journal d\'activité', path: '/activity-log' })
|
result.push({ label: 'Journal d\'activité', to: '/activity-log' })
|
||||||
} else if (path.startsWith('/admin')) {
|
} else if (path.startsWith('/admin')) {
|
||||||
result.push({ label: 'Administration', path: '/admin' })
|
result.push({ label: 'Administration', to: '/admin' })
|
||||||
} else if (path.startsWith('/documents')) {
|
} else if (path.startsWith('/documents')) {
|
||||||
result.push({ label: 'Documents', path: '/documents' })
|
result.push({ label: 'Documents', to: '/documents' })
|
||||||
} else if (path.startsWith('/comments')) {
|
} else if (path.startsWith('/comments')) {
|
||||||
result.push({ label: 'Commentaires', path: '/comments' })
|
result.push({ label: 'Commentaires', to: '/comments' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Category pages
|
// Category pages
|
||||||
else if (path.startsWith('/component-category')) {
|
else if (path.startsWith('/component-category')) {
|
||||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||||
result.push({ label: 'Catégorie', path })
|
result.push({ label: 'Catégorie', to: path })
|
||||||
} else if (path.startsWith('/piece-category')) {
|
} else if (path.startsWith('/piece-category')) {
|
||||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||||
result.push({ label: 'Catégorie', path })
|
result.push({ label: 'Catégorie', to: path })
|
||||||
} else if (path.startsWith('/product-category')) {
|
} else if (path.startsWith('/product-category')) {
|
||||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||||
result.push({ label: 'Catégorie', path })
|
result.push({ label: 'Catégorie', to: path })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create pages
|
// Create pages
|
||||||
else if (path.startsWith('/pieces/create')) {
|
else if (path.startsWith('/pieces/create')) {
|
||||||
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
|
result.push({ label: 'Pièces', to: listTo('/catalogues/pieces') })
|
||||||
result.push({ label: 'Nouvelle pièce', path })
|
result.push({ label: 'Nouvelle pièce', to: path })
|
||||||
} else if (path.startsWith('/component/create')) {
|
} else if (path.startsWith('/component/create')) {
|
||||||
result.push({ label: 'Composants', path: '/catalogues/composants' })
|
result.push({ label: 'Composants', to: listTo('/catalogues/composants') })
|
||||||
result.push({ label: 'Nouveau composant', path })
|
result.push({ label: 'Nouveau composant', to: path })
|
||||||
} else if (path.startsWith('/product/create')) {
|
} else if (path.startsWith('/product/create')) {
|
||||||
result.push({ label: 'Produits', path: '/catalogues/produits' })
|
result.push({ label: 'Produits', to: listTo('/catalogues/produits') })
|
||||||
result.push({ label: 'Nouveau produit', path })
|
result.push({ label: 'Nouveau produit', to: path })
|
||||||
} else if (path === '/machines/new') {
|
} else if (path === '/machines/new') {
|
||||||
result.push({ label: 'Parc machines', path: '/machines' })
|
result.push({ label: 'Parc machines', to: listTo('/machines') })
|
||||||
result.push({ label: 'Nouvelle machine', path })
|
result.push({ label: 'Nouvelle machine', to: path })
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -33,6 +33,9 @@ export function useMachineCreatePage() {
|
|||||||
siteId: '',
|
siteId: '',
|
||||||
reference: '',
|
reference: '',
|
||||||
cloneFromMachineId: '',
|
cloneFromMachineId: '',
|
||||||
|
// 'full' = clone complet (composants/pièces concrets) ; 'structure' = catégories
|
||||||
|
// uniquement (slots à compléter).
|
||||||
|
cloneMode: 'full' as 'full' | 'structure',
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -57,6 +60,7 @@ export function useMachineCreatePage() {
|
|||||||
result = await cloneMachine(newMachine.cloneFromMachineId, {
|
result = await cloneMachine(newMachine.cloneFromMachineId, {
|
||||||
name: newMachine.name,
|
name: newMachine.name,
|
||||||
siteId: newMachine.siteId,
|
siteId: newMachine.siteId,
|
||||||
|
mode: newMachine.cloneMode,
|
||||||
...(newMachine.reference ? { reference: newMachine.reference } : {}),
|
...(newMachine.reference ? { reference: newMachine.reference } : {}),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ export function useMachines() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cloneMachine = async (sourceId: string, data: { name: string; siteId: string; reference?: string }): Promise<ApiResponse> => {
|
const cloneMachine = async (sourceId: string, data: { name: string; siteId: string; reference?: string; mode?: 'full' | 'structure' }): Promise<ApiResponse> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await post(`/machines/${sourceId}/clone`, data)
|
const result = await post(`/machines/${sourceId}/clone`, data)
|
||||||
|
|||||||
@@ -103,6 +103,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Clone mode (visible only when a source machine is selected) -->
|
||||||
|
<div v-if="c.newMachine.cloneFromMachineId" class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Mode de clonage</span>
|
||||||
|
</label>
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:gap-6">
|
||||||
|
<label class="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="c.newMachine.cloneMode"
|
||||||
|
type="radio"
|
||||||
|
value="full"
|
||||||
|
class="radio radio-primary radio-sm mt-0.5"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
>
|
||||||
|
<span class="text-sm">
|
||||||
|
Tout cloner
|
||||||
|
<span class="block text-xs text-gray-500">Structure + composants et pièces assignés</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="c.newMachine.cloneMode"
|
||||||
|
type="radio"
|
||||||
|
value="structure"
|
||||||
|
class="radio radio-primary radio-sm mt-0.5"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
>
|
||||||
|
<span class="text-sm">
|
||||||
|
Structure seule
|
||||||
|
<span class="block text-xs text-gray-500">Catégories uniquement, slots à compléter</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex justify-end gap-3 pt-4 border-t border-base-200">
|
<div class="flex justify-end gap-3 pt-4 border-t border-base-200">
|
||||||
<NuxtLink to="/machines" class="btn btn-outline btn-sm md:btn-md">
|
<NuxtLink to="/machines" class="btn btn-outline btn-sm md:btn-md">
|
||||||
|
|||||||
@@ -132,6 +132,14 @@ class MachineStructureController extends AbstractController
|
|||||||
return $this->json(['success' => false, 'error' => 'Site introuvable.'], 404);
|
return $this->json(['success' => false, 'error' => 'Site introuvable.'], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clone mode: 'full' copies concrete components/pieces/products; 'structure'
|
||||||
|
// only keeps the slots' categories (modelType) with empty concrete entities.
|
||||||
|
$mode = $payload['mode'] ?? 'full';
|
||||||
|
if (!in_array($mode, ['full', 'structure'], true)) {
|
||||||
|
return $this->json(['success' => false, 'error' => 'mode invalide (valeurs autorisées : full, structure).'], 400);
|
||||||
|
}
|
||||||
|
$structureOnly = 'structure' === $mode;
|
||||||
|
|
||||||
// Create new machine
|
// Create new machine
|
||||||
$newMachine = new Machine();
|
$newMachine = new Machine();
|
||||||
$newMachine->setName($payload['name']);
|
$newMachine->setName($payload['name']);
|
||||||
@@ -156,13 +164,13 @@ class MachineStructureController extends AbstractController
|
|||||||
$this->cloneCustomFields($source, $newMachine);
|
$this->cloneCustomFields($source, $newMachine);
|
||||||
|
|
||||||
// Copy component links (preserving hierarchy)
|
// Copy component links (preserving hierarchy)
|
||||||
$componentLinkMap = $this->cloneComponentLinks($source, $newMachine);
|
$componentLinkMap = $this->cloneComponentLinks($source, $newMachine, $structureOnly);
|
||||||
|
|
||||||
// Copy piece links
|
// Copy piece links
|
||||||
$pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap);
|
$pieceLinkMap = $this->clonePieceLinks($source, $newMachine, $componentLinkMap, $structureOnly);
|
||||||
|
|
||||||
// Copy product links
|
// Copy product links
|
||||||
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
|
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap, $structureOnly);
|
||||||
|
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
|
|
||||||
@@ -215,7 +223,7 @@ class MachineStructureController extends AbstractController
|
|||||||
/**
|
/**
|
||||||
* @return array<string, MachineComponentLink> Map of old link ID → new link
|
* @return array<string, MachineComponentLink> Map of old link ID → new link
|
||||||
*/
|
*/
|
||||||
private function cloneComponentLinks(Machine $source, Machine $target): array
|
private function cloneComponentLinks(Machine $source, Machine $target, bool $structureOnly = false): array
|
||||||
{
|
{
|
||||||
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
$sourceLinks = $this->machineComponentLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||||
$linkMap = [];
|
$linkMap = [];
|
||||||
@@ -224,6 +232,16 @@ class MachineStructureController extends AbstractController
|
|||||||
foreach ($sourceLinks as $link) {
|
foreach ($sourceLinks as $link) {
|
||||||
$newLink = new MachineComponentLink();
|
$newLink = new MachineComponentLink();
|
||||||
$newLink->setMachine($target);
|
$newLink->setMachine($target);
|
||||||
|
|
||||||
|
if ($structureOnly) {
|
||||||
|
// Keep only the slot category; leave the concrete component empty.
|
||||||
|
$newLink->setModelType($link->getModelType() ?? $link->getComposant()?->getTypeComposant());
|
||||||
|
$this->entityManager->persist($newLink);
|
||||||
|
$linkMap[$link->getId()] = $newLink;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$newLink->setComposant($link->getComposant());
|
$newLink->setComposant($link->getComposant());
|
||||||
$newLink->setNameOverride($link->getNameOverride());
|
$newLink->setNameOverride($link->getNameOverride());
|
||||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||||
@@ -259,7 +277,7 @@ class MachineStructureController extends AbstractController
|
|||||||
*
|
*
|
||||||
* @return array<string, MachinePieceLink> Map of old link ID → new link
|
* @return array<string, MachinePieceLink> Map of old link ID → new link
|
||||||
*/
|
*/
|
||||||
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap): array
|
private function clonePieceLinks(Machine $source, Machine $target, array $componentLinkMap, bool $structureOnly = false): array
|
||||||
{
|
{
|
||||||
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
$sourceLinks = $this->machinePieceLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||||
$linkMap = [];
|
$linkMap = [];
|
||||||
@@ -267,17 +285,27 @@ class MachineStructureController extends AbstractController
|
|||||||
foreach ($sourceLinks as $link) {
|
foreach ($sourceLinks as $link) {
|
||||||
$newLink = new MachinePieceLink();
|
$newLink = new MachinePieceLink();
|
||||||
$newLink->setMachine($target);
|
$newLink->setMachine($target);
|
||||||
$newLink->setPiece($link->getPiece());
|
|
||||||
$newLink->setNameOverride($link->getNameOverride());
|
|
||||||
$newLink->setReferenceOverride($link->getReferenceOverride());
|
|
||||||
$newLink->setPrixOverride($link->getPrixOverride());
|
|
||||||
$newLink->setQuantity($link->getQuantity());
|
|
||||||
|
|
||||||
$parent = $link->getParentLink();
|
$parent = $link->getParentLink();
|
||||||
if ($parent && isset($componentLinkMap[$parent->getId()])) {
|
if ($parent && isset($componentLinkMap[$parent->getId()])) {
|
||||||
$newLink->setParentLink($componentLinkMap[$parent->getId()]);
|
$newLink->setParentLink($componentLinkMap[$parent->getId()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($structureOnly) {
|
||||||
|
// Keep only the slot category; leave the concrete piece empty.
|
||||||
|
$newLink->setModelType($link->getModelType() ?? $link->getPiece()?->getTypePiece());
|
||||||
|
$this->entityManager->persist($newLink);
|
||||||
|
$linkMap[$link->getId()] = $newLink;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$newLink->setPiece($link->getPiece());
|
||||||
|
$newLink->setNameOverride($link->getNameOverride());
|
||||||
|
$newLink->setReferenceOverride($link->getReferenceOverride());
|
||||||
|
$newLink->setPrixOverride($link->getPrixOverride());
|
||||||
|
$newLink->setQuantity($link->getQuantity());
|
||||||
|
|
||||||
$this->entityManager->persist($newLink);
|
$this->entityManager->persist($newLink);
|
||||||
|
|
||||||
foreach ($link->getContextFieldValues() as $cfv) {
|
foreach ($link->getContextFieldValues() as $cfv) {
|
||||||
@@ -305,6 +333,7 @@ class MachineStructureController extends AbstractController
|
|||||||
Machine $target,
|
Machine $target,
|
||||||
array $componentLinkMap,
|
array $componentLinkMap,
|
||||||
array $pieceLinkMap,
|
array $pieceLinkMap,
|
||||||
|
bool $structureOnly = false,
|
||||||
): void {
|
): void {
|
||||||
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
$sourceLinks = $this->machineProductLinkRepository->findBy(['machine' => $source], ['createdAt' => 'ASC']);
|
||||||
$linkMap = [];
|
$linkMap = [];
|
||||||
@@ -313,7 +342,13 @@ class MachineStructureController extends AbstractController
|
|||||||
foreach ($sourceLinks as $link) {
|
foreach ($sourceLinks as $link) {
|
||||||
$newLink = new MachineProductLink();
|
$newLink = new MachineProductLink();
|
||||||
$newLink->setMachine($target);
|
$newLink->setMachine($target);
|
||||||
$newLink->setProduct($link->getProduct());
|
|
||||||
|
if ($structureOnly) {
|
||||||
|
// Keep only the slot category; leave the concrete product empty.
|
||||||
|
$newLink->setModelType($link->getModelType() ?? $link->getProduct()?->getTypeProduct());
|
||||||
|
} else {
|
||||||
|
$newLink->setProduct($link->getProduct());
|
||||||
|
}
|
||||||
|
|
||||||
$parentComponent = $link->getParentComponentLink();
|
$parentComponent = $link->getParentComponentLink();
|
||||||
if ($parentComponent && isset($componentLinkMap[$parentComponent->getId()])) {
|
if ($parentComponent && isset($componentLinkMap[$parentComponent->getId()])) {
|
||||||
@@ -774,7 +809,7 @@ class MachineStructureController extends AbstractController
|
|||||||
$pieces = [];
|
$pieces = [];
|
||||||
foreach ($composant->getPieceSlots() as $slot) {
|
foreach ($composant->getPieceSlots() as $slot) {
|
||||||
$selectedPiece = $this->ensurePieceExists($slot->getSelectedPiece());
|
$selectedPiece = $this->ensurePieceExists($slot->getSelectedPiece());
|
||||||
$pieceData = [
|
$pieceData = [
|
||||||
'slotId' => $slot->getId(),
|
'slotId' => $slot->getId(),
|
||||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||||
'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
|
'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
|
||||||
@@ -824,6 +859,7 @@ class MachineStructureController extends AbstractController
|
|||||||
if (null === $piece) {
|
if (null === $piece) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->entityManager->initializeObject($piece);
|
$this->entityManager->initializeObject($piece);
|
||||||
|
|
||||||
@@ -844,6 +880,7 @@ class MachineStructureController extends AbstractController
|
|||||||
if (null === $cf) {
|
if (null === $cf) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$this->entityManager->initializeObject($cf);
|
$this->entityManager->initializeObject($cf);
|
||||||
|
|
||||||
|
|||||||
@@ -308,4 +308,88 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
|
|||||||
$this->assertCount(1, $sourceLink['contextCustomFieldValues']);
|
$this->assertCount(1, $sourceLink['contextCustomFieldValues']);
|
||||||
$this->assertSame('1500', $sourceLink['contextCustomFieldValues'][0]['value']);
|
$this->assertSame('1500', $sourceLink['contextCustomFieldValues'][0]['value']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCloneMachineStructureModeKeepsCategoriesWithoutConcreteEntities(): void
|
||||||
|
{
|
||||||
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
|
$site = $this->createSite('Site Structure');
|
||||||
|
$compType = $this->createModelType('Motor Struct', 'MOTST', ModelCategory::COMPONENT);
|
||||||
|
$pieceType = $this->createModelType('Bearing Struct', 'BRGST', ModelCategory::PIECE);
|
||||||
|
$contextField = $this->createCustomField(
|
||||||
|
name: 'RPM Struct',
|
||||||
|
type: 'number',
|
||||||
|
typeComposant: $compType,
|
||||||
|
machineContextOnly: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
$source = $this->createMachine('Source Struct Machine', $site);
|
||||||
|
$composant = $this->createComposant('Motor ST', 'MOTST-001', $compType);
|
||||||
|
$componentLink = $this->createMachineComponentLink($source, $composant);
|
||||||
|
$piece = $this->createPiece('Bearing ST', 'BRGST-001', $pieceType);
|
||||||
|
$this->createMachinePieceLink($source, $piece, $componentLink);
|
||||||
|
|
||||||
|
$this->createCustomFieldValue(
|
||||||
|
customField: $contextField,
|
||||||
|
value: '4200',
|
||||||
|
composant: $composant,
|
||||||
|
machineComponentLink: $componentLink,
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||||
|
'json' => [
|
||||||
|
'name' => 'Cloned Struct Machine',
|
||||||
|
'siteId' => $site->getId(),
|
||||||
|
'mode' => 'structure',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(201);
|
||||||
|
$data = $response->toArray();
|
||||||
|
|
||||||
|
// Component slot: category preserved, concrete component dropped, no context values.
|
||||||
|
$clonedComponent = $data['componentLinks'][0] ?? null;
|
||||||
|
$this->assertNotNull($clonedComponent, 'Structure clone should expose the component slot');
|
||||||
|
$this->assertTrue($clonedComponent['pendingEntity']);
|
||||||
|
$this->assertNull($clonedComponent['composantId']);
|
||||||
|
$this->assertSame($compType->getId(), $clonedComponent['modelTypeId']);
|
||||||
|
$this->assertCount(0, $clonedComponent['contextCustomFieldValues']);
|
||||||
|
|
||||||
|
// Piece slot: category preserved, concrete piece dropped.
|
||||||
|
$clonedPiece = $data['pieceLinks'][0] ?? null;
|
||||||
|
$this->assertNotNull($clonedPiece, 'Structure clone should expose the piece slot');
|
||||||
|
$this->assertTrue($clonedPiece['pendingEntity']);
|
||||||
|
$this->assertNull($clonedPiece['pieceId']);
|
||||||
|
$this->assertSame($pieceType->getId(), $clonedPiece['modelTypeId']);
|
||||||
|
|
||||||
|
// Source machine stays intact (still has its concrete component).
|
||||||
|
$sourceData = $client->request('GET', '/api/machines/'.$source->getId().'/structure')->toArray();
|
||||||
|
$this->assertFalse($sourceData['componentLinks'][0]['pendingEntity']);
|
||||||
|
$this->assertSame($composant->getId(), $sourceData['componentLinks'][0]['composantId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCloneMachineFullModeStillCopiesConcreteEntities(): void
|
||||||
|
{
|
||||||
|
$client = $this->createGestionnaireClient();
|
||||||
|
|
||||||
|
$site = $this->createSite('Site Full');
|
||||||
|
$compType = $this->createModelType('Motor Full', 'MOTFL', ModelCategory::COMPONENT);
|
||||||
|
$source = $this->createMachine('Source Full Machine', $site);
|
||||||
|
$composant = $this->createComposant('Motor FL', 'MOTFL-001', $compType);
|
||||||
|
$this->createMachineComponentLink($source, $composant);
|
||||||
|
|
||||||
|
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||||
|
'json' => [
|
||||||
|
'name' => 'Cloned Full Machine',
|
||||||
|
'siteId' => $site->getId(),
|
||||||
|
'mode' => 'full',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(201);
|
||||||
|
$clonedComponent = $response->toArray()['componentLinks'][0] ?? null;
|
||||||
|
$this->assertNotNull($clonedComponent);
|
||||||
|
$this->assertFalse($clonedComponent['pendingEntity']);
|
||||||
|
$this->assertSame($composant->getId(), $clonedComponent['composantId']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user