feat(skeleton) : add custom PUT processor and edit guard for linked machines
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
168
CLAUDE.md
Normal file
168
CLAUDE.md
Normal file
@@ -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)
|
||||
```
|
||||
<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 :
|
||||
- `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)
|
||||
Submodule Inventory_frontend updated: efd0fbe407...546cc37a09
@@ -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<int, TypeMachineComponentRequirement>
|
||||
*/
|
||||
#[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<int, TypeMachinePieceRequirement>
|
||||
*/
|
||||
#[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<int, TypeMachineProductRequirement>
|
||||
*/
|
||||
#[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;
|
||||
}
|
||||
|
||||
211
src/State/TypeMachinePutProcessor.php
Normal file
211
src/State/TypeMachinePutProcessor.php
Normal file
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\CustomField;
|
||||
use App\Entity\ModelType;
|
||||
use App\Entity\TypeMachine;
|
||||
use App\Entity\TypeMachineComponentRequirement;
|
||||
use App\Entity\TypeMachinePieceRequirement;
|
||||
use App\Entity\TypeMachineProductRequirement;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
use function array_key_exists;
|
||||
use function is_string;
|
||||
|
||||
final class TypeMachinePutProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly RequestStack $requestStack,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $uriVariables
|
||||
* @param array<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user