From 7a5dd0b5552e0c721e70fec6061b684202343e52 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 3 Mar 2026 10:13:45 +0100 Subject: [PATCH] feat(skeleton) : add custom PUT processor and edit guard for linked machines Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 168 ++++++++++++++++++++ Inventory_frontend | 2 +- src/Entity/TypeMachine.php | 27 +--- src/State/TypeMachinePutProcessor.php | 211 ++++++++++++++++++++++++++ 4 files changed, 388 insertions(+), 20 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/State/TypeMachinePutProcessor.php diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..07e141a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,168 @@ +# 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. + +## Stack + +| Layer | Tech | Version | +|-------|------|---------| +| Backend | Symfony + API Platform | 8.0 / ^4.2 | +| PHP | PHP | >=8.4 | +| Database | PostgreSQL | 16 | +| 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) | | +| Containers | Docker Compose | | + +## Project Structure + +``` +Inventory/ # Backend Symfony (repo principal) +├── src/Entity/ # Entités Doctrine (annotations PHP 8 attributes) +├── src/Controller/ # Controllers custom (session, comments, audit…) +├── src/EventSubscriber/ # Audit subscribers (onFlush) +├── 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) +├── Inventory_frontend/ # ← SUBMODULE GIT (repo séparé) +│ ├── app/pages/ # Pages Nuxt (file-based routing) +│ ├── app/components/ # Composants Vue (auto-imported) +│ ├── app/composables/ # Composables Vue +│ ├── app/shared/ # Types, utils, validation +│ ├── app/middleware/ # Auth middleware global +│ └── app/services/ # Service layer (wrappers useApi) +``` + +## Key Commands + +```bash +# Docker +make start # Démarrer les containers +make stop # Arrêter +make shell # Shell dans le container PHP +make install # Install complet (composer + npm + build) + +# Backend +make test # PHPUnit +docker compose exec php vendor/bin/php-cs-fixer fix # Linter PHP +docker compose exec php php bin/console doctrine:migrations:migrate + +# Frontend (dans Inventory_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) + +# Release +./scripts/release.sh patch # Bump patch version (ou minor/major) +``` + +## Git Conventions + +### Branches +- `master` — production +- `develop` — branche principale de dev (cible des PR) +- `feat/xxx`, `fix/xxx`, `refactor/xxx` — branches de travail + +### Commit Message Format (enforced by hook) +``` +() : +``` +**Espace obligatoire autour du `:`**. Types autorisés (minuscules) : +`build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`, `wip` + +Exemples : +- `feat(auth) : add login page` +- `fix(machines) : prevent null crash on skeleton creation` + +### 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 `Inventory_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`, `TypeMachine`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile` + +### 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 + +### Rôles (hiérarchie) +``` +ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER +``` + +### 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`) + +## 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 +- **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 + +### Toujours faire AVANT de modifier du code +1. **Lire le fichier** avant de l'éditer — ne jamais proposer de changements sur du code non lu +2. **Comprendre le pattern existant** — reproduire le style du fichier (noms, indentation, structure) +3. **Vérifier les deux repos** — un changement peut impacter backend ET frontend + +### Après chaque modification +1. Backend PHP : `docker compose exec php vendor/bin/php-cs-fixer fix` +2. Frontend : `npm run lint:fix` puis `npx nuxi typecheck` si fichiers TS modifiés + +### 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 + +### 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) + +## 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) diff --git a/Inventory_frontend b/Inventory_frontend index efd0fbe..546cc37 160000 --- a/Inventory_frontend +++ b/Inventory_frontend @@ -1 +1 @@ -Subproject commit efd0fbe4075f69e48030ea1a1ecfdc8f84751d53 +Subproject commit 546cc37a09a1eea69c9b931fd85fa64b4c8170e3 diff --git a/src/Entity/TypeMachine.php b/src/Entity/TypeMachine.php index 0898fe5..2bf3dfb 100644 --- a/src/Entity/TypeMachine.php +++ b/src/Entity/TypeMachine.php @@ -12,6 +12,7 @@ use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use App\Repository\TypeMachineRepository; +use App\State\TypeMachinePutProcessor; use DateTimeImmutable; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; @@ -30,7 +31,7 @@ use Symfony\Component\Validator\Constraints as Assert; 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 Put(security: "is_granted('ROLE_GESTIONNAIRE')", processor: TypeMachinePutProcessor::class), new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"), ], paginationClientItemsPerPage: true, @@ -100,21 +101,21 @@ class TypeMachine /** * @var Collection */ - #[ORM\OneToMany(targetEntity: TypeMachineComponentRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'])] + #[ORM\OneToMany(targetEntity: TypeMachineComponentRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'], orphanRemoval: true)] #[ApiProperty(readableLink: true, writableLink: true)] private Collection $componentRequirements; /** * @var Collection */ - #[ORM\OneToMany(targetEntity: TypeMachinePieceRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'])] + #[ORM\OneToMany(targetEntity: TypeMachinePieceRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'], orphanRemoval: true)] #[ApiProperty(readableLink: true, writableLink: true)] private Collection $pieceRequirements; /** * @var Collection */ - #[ORM\OneToMany(targetEntity: TypeMachineProductRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'])] + #[ORM\OneToMany(targetEntity: TypeMachineProductRequirement::class, mappedBy: 'typeMachine', cascade: ['persist', 'remove'], orphanRemoval: true)] #[ApiProperty(readableLink: true, writableLink: true)] private Collection $productRequirements; @@ -319,11 +320,7 @@ class TypeMachine public function removeComponentRequirement(TypeMachineComponentRequirement $componentRequirement): static { - if ($this->componentRequirements->removeElement($componentRequirement)) { - if ($componentRequirement->getTypeMachine() === $this) { - $componentRequirement->setTypeMachine(null); - } - } + $this->componentRequirements->removeElement($componentRequirement); return $this; } @@ -348,11 +345,7 @@ class TypeMachine public function removePieceRequirement(TypeMachinePieceRequirement $pieceRequirement): static { - if ($this->pieceRequirements->removeElement($pieceRequirement)) { - if ($pieceRequirement->getTypeMachine() === $this) { - $pieceRequirement->setTypeMachine(null); - } - } + $this->pieceRequirements->removeElement($pieceRequirement); return $this; } @@ -377,11 +370,7 @@ class TypeMachine public function removeProductRequirement(TypeMachineProductRequirement $productRequirement): static { - if ($this->productRequirements->removeElement($productRequirement)) { - if ($productRequirement->getTypeMachine() === $this) { - $productRequirement->setTypeMachine(null); - } - } + $this->productRequirements->removeElement($productRequirement); return $this; } diff --git a/src/State/TypeMachinePutProcessor.php b/src/State/TypeMachinePutProcessor.php new file mode 100644 index 0000000..8147d13 --- /dev/null +++ b/src/State/TypeMachinePutProcessor.php @@ -0,0 +1,211 @@ + $uriVariables + * @param array $context + */ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TypeMachine + { + $typeMachine = $this->em->getRepository(TypeMachine::class)->find($uriVariables['id']); + + if (!$typeMachine) { + throw new NotFoundHttpException('Type de machine non trouvé.'); + } + + // Guard: cannot edit if machines are linked + if (!$typeMachine->getMachines()->isEmpty()) { + throw new HttpException(422, 'Ce type de machine ne peut pas être modifié car des machines y sont rattachées.'); + } + + $request = $this->requestStack->getCurrentRequest(); + $payload = json_decode($request->getContent(), true) ?? []; + + $this->updateScalarProperties($typeMachine, $payload); + + if (array_key_exists('customFields', $payload)) { + $this->replaceCustomFields($typeMachine, $payload['customFields'] ?? []); + } + + if (array_key_exists('componentRequirements', $payload)) { + $this->replaceComponentRequirements($typeMachine, $payload['componentRequirements'] ?? []); + } + + if (array_key_exists('pieceRequirements', $payload)) { + $this->replacePieceRequirements($typeMachine, $payload['pieceRequirements'] ?? []); + } + + if (array_key_exists('productRequirements', $payload)) { + $this->replaceProductRequirements($typeMachine, $payload['productRequirements'] ?? []); + } + + $this->em->flush(); + + return $typeMachine; + } + + private function updateScalarProperties(TypeMachine $typeMachine, array $payload): void + { + if (isset($payload['name'])) { + $typeMachine->setName($payload['name']); + } + + if (array_key_exists('description', $payload)) { + $typeMachine->setDescription($payload['description']); + } + + if (array_key_exists('category', $payload)) { + $typeMachine->setCategory($payload['category']); + } + + if (array_key_exists('maintenanceFrequency', $payload)) { + $typeMachine->setMaintenanceFrequency($payload['maintenanceFrequency']); + } + + if (array_key_exists('components', $payload)) { + $typeMachine->setComponents($payload['components']); + } + + if (array_key_exists('criticalParts', $payload)) { + $typeMachine->setCriticalParts($payload['criticalParts']); + } + + if (array_key_exists('machinePieces', $payload)) { + $typeMachine->setMachinePieces($payload['machinePieces']); + } + + if (array_key_exists('specifications', $payload)) { + $typeMachine->setSpecifications($payload['specifications']); + } + } + + private function replaceCustomFields(TypeMachine $typeMachine, array $fieldsData): void + { + foreach ($typeMachine->getCustomFields()->toArray() as $old) { + $typeMachine->removeCustomField($old); + } + + foreach ($fieldsData as $index => $data) { + $field = new CustomField(); + $field->setName($data['name'] ?? ''); + $field->setType($data['type'] ?? 'text'); + $field->setRequired($data['required'] ?? false); + $field->setOptions($data['options'] ?? null); + $field->setOrderIndex($data['orderIndex'] ?? $index); + $typeMachine->addCustomField($field); + } + } + + private function replaceComponentRequirements(TypeMachine $typeMachine, array $requirementsData): void + { + foreach ($typeMachine->getComponentRequirements()->toArray() as $old) { + $typeMachine->removeComponentRequirement($old); + } + + foreach ($requirementsData as $index => $data) { + $req = new TypeMachineComponentRequirement(); + $req->setLabel($data['label'] ?? null); + $req->setMinCount($data['minCount'] ?? 1); + $req->setMaxCount($data['maxCount'] ?? null); + $req->setRequired($data['required'] ?? true); + $req->setAllowNewModels($data['allowNewModels'] ?? true); + $req->setOrderIndex($data['orderIndex'] ?? $index); + + $modelType = $this->resolveModelType($data['typeComposant'] ?? null); + if ($modelType) { + $req->setTypeComposant($modelType); + } + + $typeMachine->addComponentRequirement($req); + } + } + + private function replacePieceRequirements(TypeMachine $typeMachine, array $requirementsData): void + { + foreach ($typeMachine->getPieceRequirements()->toArray() as $old) { + $typeMachine->removePieceRequirement($old); + } + + foreach ($requirementsData as $index => $data) { + $req = new TypeMachinePieceRequirement(); + $req->setLabel($data['label'] ?? null); + $req->setMinCount($data['minCount'] ?? 0); + $req->setMaxCount($data['maxCount'] ?? null); + $req->setRequired($data['required'] ?? false); + $req->setAllowNewModels($data['allowNewModels'] ?? true); + $req->setOrderIndex($data['orderIndex'] ?? $index); + + $modelType = $this->resolveModelType($data['typePiece'] ?? null); + if ($modelType) { + $req->setTypePiece($modelType); + } + + $typeMachine->addPieceRequirement($req); + } + } + + private function replaceProductRequirements(TypeMachine $typeMachine, array $requirementsData): void + { + foreach ($typeMachine->getProductRequirements()->toArray() as $old) { + $typeMachine->removeProductRequirement($old); + } + + foreach ($requirementsData as $index => $data) { + $req = new TypeMachineProductRequirement(); + $req->setLabel($data['label'] ?? null); + $req->setMinCount($data['minCount'] ?? 0); + $req->setMaxCount($data['maxCount'] ?? null); + $req->setRequired($data['required'] ?? false); + $req->setAllowNewModels($data['allowNewModels'] ?? true); + $req->setOrderIndex($data['orderIndex'] ?? $index); + + $modelType = $this->resolveModelType($data['typeProduct'] ?? null); + if ($modelType) { + $req->setTypeProduct($modelType); + } + + $typeMachine->addProductRequirement($req); + } + } + + private function resolveModelType(mixed $value): ?ModelType + { + if (!$value) { + return null; + } + + $id = $value; + + if (is_string($value) && preg_match('#/api/model_types/(.+)$#', $value, $matches)) { + $id = $matches[1]; + } + + return $this->em->getReference(ModelType::class, $id); + } +}