diff --git a/docs/superpowers/plans/2026-03-12-piece-quantity.md b/docs/superpowers/plans/2026-03-12-piece-quantity.md new file mode 100644 index 0000000..6d69b09 --- /dev/null +++ b/docs/superpowers/plans/2026-03-12-piece-quantity.md @@ -0,0 +1,546 @@ +# Piece Quantity Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a quantity field to pieces — on `MachinePieceLink` for machine-direct pieces, and in `Composant.structure.pieces[]` JSON for composant pieces. + +**Architecture:** Quantity lives on the relationship, not the catalogue entity. For machine-direct pieces, a new `quantity` integer column on `MachinePieceLink` (default 1). For composant pieces, a `quantity` key in the existing `structure.pieces[]` JSON (default 1). Display: "×N" after piece name, hidden when N=1. + +**Tech Stack:** Symfony 8 / API Platform, Doctrine ORM, PostgreSQL, Nuxt 4, Vue 3 Composition API, TypeScript, DaisyUI 5 + +**Spec:** `docs/superpowers/specs/2026-03-12-piece-quantity-design.md` + +--- + +## Chunk 1: Backend + +### Task 1: Entity + Migration + +**Files:** +- Modify: `src/Entity/MachinePieceLink.php` +- Existing: `migrations/Version20260309150000.php` (already written, untracked) + +- [ ] **Step 1: Add quantity field to MachinePieceLink entity** + +In `src/Entity/MachinePieceLink.php`, add after the `prixOverride` field (line 69): + +```php +#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])] +#[Assert\GreaterThanOrEqual(1)] +private int $quantity = 1; +``` + +Add the import at the top if not present: +```php +use Symfony\Component\Validator\Constraints as Assert; +``` + +Add getter/setter after existing methods (before closing brace): + +```php +public function getQuantity(): int +{ + return $this->quantity; +} + +public function setQuantity(int $quantity): static +{ + $this->quantity = $quantity; + + return $this; +} +``` + +- [ ] **Step 2: Stage the migration file** + +The migration `migrations/Version20260309150000.php` already exists (untracked). Verify its content matches: + +```php +public function up(Schema $schema): void +{ + $this->addSql('ALTER TABLE machine_piece_links ADD COLUMN IF NOT EXISTS quantity INTEGER NOT NULL DEFAULT 1'); +} +``` + +- [ ] **Step 3: Run php-cs-fixer** + +Run: `make php-cs-fixer-allow-risky` + +- [ ] **Step 4: Run tests to verify nothing is broken** + +Run: `make test` +Expected: All 167 tests pass (OK, with possible deprecation warnings) + +- [ ] **Step 5: Commit** + +```bash +git add src/Entity/MachinePieceLink.php migrations/Version20260309150000.php +git commit -m "feat(piece) : add quantity field to MachinePieceLink entity + migration" +``` + +--- + +### Task 2: MachineStructureController — Normalization + PATCH + Clone + +**Files:** +- Modify: `src/Controller/MachineStructureController.php` + +- [ ] **Step 1: Add quantity to `normalizePieceLinks()`** + +In `src/Controller/MachineStructureController.php`, method `normalizePieceLinks()` (line ~623-641). + +Add `'quantity'` to the returned array, after `'overrides'`: + +```php +'quantity' => $this->resolvePieceQuantity($link), +``` + +Add a new private method after `normalizePieceLinks()`: + +```php +private function resolvePieceQuantity(MachinePieceLink $link): int +{ + $parentLink = $link->getParentLink(); + + if (!$parentLink) { + return $link->getQuantity(); + } + + $composant = $parentLink->getComposant(); + $structure = $composant->getStructure(); + + if (!is_array($structure) || !isset($structure['pieces']) || !is_array($structure['pieces'])) { + return 1; + } + + $piece = $link->getPiece(); + $typePiece = $piece->getTypePiece(); + $typePieceId = $typePiece?->getId(); + + foreach ($structure['pieces'] as $pieceDef) { + if (!is_array($pieceDef)) { + continue; + } + if (isset($pieceDef['typePieceId']) && $pieceDef['typePieceId'] === $typePieceId) { + return (int) ($pieceDef['quantity'] ?? 1); + } + } + + return 1; +} +``` + +**Note:** Matching is done by `typePieceId`. If a composant has two pieces of the same type, they will get the same quantity (first match). This is an acceptable limitation for now — duplicates of the same piece type in a composant are rare. + +- [ ] **Step 2: Apply quantity in `applyPieceLinks()`** + +In method `applyPieceLinks()` (line ~366-422), add quantity application after `$this->applyOverrides($link, $entry['overrides'] ?? null);` (line ~396): + +```php +if (!isset($entry['parentComponentLinkId']) && !isset($entry['parentLinkId'])) { + $quantity = isset($entry['quantity']) ? (int) $entry['quantity'] : $link->getQuantity(); + $link->setQuantity(max(1, $quantity)); +} +``` + +**Key behavior:** +- Only applies to direct machine pieces (no parent component link) +- If `quantity` not in payload: preserves existing value +- If `quantity` in payload: sets it, with floor of 1 +- For composant-child pieces: quantity is ignored (comes from composant structure) + +- [ ] **Step 3: Copy quantity in `clonePieceLinks()`** + +In method `clonePieceLinks()` (line ~233-256), add after `$newLink->setPrixOverride($link->getPrixOverride());` (line ~244): + +```php +$newLink->setQuantity($link->getQuantity()); +``` + +- [ ] **Step 4: Run php-cs-fixer** + +Run: `make php-cs-fixer-allow-risky` + +- [ ] **Step 5: Run tests** + +Run: `make test` +Expected: All tests pass + +- [ ] **Step 6: Commit** + +```bash +git add src/Controller/MachineStructureController.php +git commit -m "feat(piece) : add quantity to structure normalization, PATCH and clone" +``` + +--- + +### Task 3: Backend Tests + +**Files:** +- Modify: `tests/Api/Entity/MachinePieceLinkTest.php` +- Modify: `tests/AbstractApiTestCase.php` (factory method) + +- [ ] **Step 1: Update factory method to support quantity** + +In `tests/AbstractApiTestCase.php`, update `createMachinePieceLink()` to accept an optional quantity parameter: + +```php +protected function createMachinePieceLink(Machine $machine, Piece $piece, ?MachineComponentLink $parentLink = null, int $quantity = 1): MachinePieceLink +{ + $link = new MachinePieceLink(); + $link->setMachine($machine); + $link->setPiece($piece); + $link->setQuantity($quantity); + if (null !== $parentLink) { + $link->setParentLink($parentLink); + } + + $em = $this->getEntityManager(); + $em->persist($link); + $em->flush(); + + return $link; +} +``` + +- [ ] **Step 2: Add test for POST with explicit quantity** + +In `tests/Api/Entity/MachinePieceLinkTest.php`, add. Follow the existing test pattern — use `$this->assertJsonContains()` and include the `headers` key with `Content-Type`: + +```php +public function testPostWithQuantity(): void +{ + $client = $this->createGestionnaireClient(); + $machine = $this->createMachine(); + $piece = $this->createPiece(); + + $client->request('POST', '/api/machine_piece_links', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'machine' => '/api/machines/' . $machine->getId(), + 'piece' => '/api/pieces/' . $piece->getId(), + 'quantity' => 5, + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains(['quantity' => 5]); +} +``` + +- [ ] **Step 3: Add test for POST without quantity (default = 1)** + +```php +public function testPostDefaultQuantity(): void +{ + $client = $this->createGestionnaireClient(); + $machine = $this->createMachine(); + $piece = $this->createPiece(); + + $client->request('POST', '/api/machine_piece_links', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'machine' => '/api/machines/' . $machine->getId(), + 'piece' => '/api/pieces/' . $piece->getId(), + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains(['quantity' => 1]); +} +``` + +- [ ] **Step 4: Run tests** + +Run: `make test` +Expected: All tests pass including new ones + +- [ ] **Step 5: Run php-cs-fixer** + +Run: `make php-cs-fixer-allow-risky` + +- [ ] **Step 6: Commit** + +```bash +git add tests/Api/Entity/MachinePieceLinkTest.php tests/AbstractApiTestCase.php +git commit -m "test(piece) : add quantity tests for MachinePieceLink" +``` + +--- + +## Chunk 2: Frontend + +### Task 4: TypeScript Types + Sanitization + Hydration Functions + +**Files:** +- Modify: `Inventory_frontend/app/shared/types/inventory.ts` +- Modify: `Inventory_frontend/app/shared/model/componentStructure.ts` +- Modify: `Inventory_frontend/app/shared/model/componentStructureSanitize.ts` +- Modify: `Inventory_frontend/app/shared/model/componentStructureHydrate.ts` +- Modify: `Inventory_frontend/app/shared/utils/structureAssignmentHelpers.ts` + +- [ ] **Step 1: Add `quantity` to `ComponentModelPiece` type** + +In `Inventory_frontend/app/shared/types/inventory.ts`, add `quantity` to the `ComponentModelPiece` interface (after `role`, line ~23): + +```typescript +quantity?: number +``` + +- [ ] **Step 2: Add `quantity` to `validatePiece()` in same file** + +In `Inventory_frontend/app/shared/types/inventory.ts`, in `validatePiece()` (line ~144-172): + +After `const role = ensureString(value.role)` (line ~161), add: + +```typescript +const quantity = typeof value.quantity === 'number' && value.quantity >= 1 ? value.quantity : undefined +``` + +And in the return object, add after the `role` spread: + +```typescript +...(quantity ? { quantity } : {}), +``` + +- [ ] **Step 3: Update `sanitizePieces()` to preserve quantity** + +In `Inventory_frontend/app/shared/model/componentStructureSanitize.ts`, in `sanitizePieces()` (~line 130-188). + +After the existing field extractions, add: + +```typescript +const quantity = typeof piece?.quantity === 'number' && piece.quantity >= 1 ? piece.quantity : undefined +``` + +In the result object construction, add alongside existing fields (follow the `if (field) { result.field = field }` pattern used in this function): + +```typescript +if (quantity !== undefined) { + result.quantity = quantity +} +``` + +- [ ] **Step 4: Update `normalizeStructureForSave()` to include quantity** + +In `Inventory_frontend/app/shared/model/componentStructure.ts`, in `normalizeStructureForSave()` (~lines 164-179), add in the piece payload mapping after the `reference` check: + +```typescript +if ((piece as any).quantity !== undefined && (piece as any).quantity >= 1) { + payload.quantity = (piece as any).quantity +} +``` + +**Note:** Always send quantity when defined (including 1), so the backend always has an explicit value. + +- [ ] **Step 5: Update `hydratePieces()` and `mapComponentPieces()` to preserve quantity** + +In `Inventory_frontend/app/shared/model/componentStructureHydrate.ts`: + +In `hydratePieces()` (line ~95-107), add to the mapped object: + +```typescript +...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}), +``` + +In `mapComponentPieces()` (line ~168-179), add to the mapped object: + +```typescript +...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}), +``` + +- [ ] **Step 6: Update `sanitizePieceDefinition()` to preserve quantity** + +In `Inventory_frontend/app/shared/utils/structureAssignmentHelpers.ts`, in `sanitizePieceDefinition()` (~lines 172-180), add to the `stripNullish()` object: + +```typescript +quantity: typeof (definition as any).quantity === 'number' ? (definition as any).quantity : null, +``` + +- [ ] **Step 7: Run lint + typecheck** + +```bash +cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck +``` +Expected: 0 errors + +- [ ] **Step 8: Commit** + +```bash +cd Inventory_frontend +git add app/shared/types/inventory.ts app/shared/model/componentStructure.ts app/shared/model/componentStructureSanitize.ts app/shared/model/componentStructureHydrate.ts app/shared/utils/structureAssignmentHelpers.ts +git commit -m "feat(piece) : add quantity field to piece types, sanitization and hydration" +``` + +--- + +### Task 5: Composant Structure Editor — Quantity Input + +**Files:** +- Modify: `Inventory_frontend/app/components/StructureNodeEditor.vue` (piece section, lines ~229-299) +- Modify: `Inventory_frontend/app/composables/useStructureNodeCrud.ts` (`addPiece()`, lines ~110-118) + +**Context:** `StructureNodeEditor.vue` renders the composant structure editor. The piece section (lines ~236-293) currently shows only a `select` for `typePieceId` and a delete button. The `addPiece()` function in `useStructureNodeCrud.ts` creates new piece entries with default fields. + +- [ ] **Step 1: Add default quantity to `addPiece()`** + +In `Inventory_frontend/app/composables/useStructureNodeCrud.ts`, in `addPiece()` (line ~110-118), add `quantity: 1` to the pushed object: + +```typescript +const addPiece = () => { + ensureArray('pieces') + props.node.pieces!.push({ + typePieceId: '', + typePieceLabel: '', + reference: '', + familyCode: '', + role: '', + quantity: 1, + }) +} +``` + +- [ ] **Step 2: Add quantity input in `StructureNodeEditor.vue`** + +In `Inventory_frontend/app/components/StructureNodeEditor.vue`, in the piece item rendering section (inside the `v-for` loop for pieces, line ~256-292), add a quantity input next to the existing piece type `select`. Place it after the select and before the delete button: + +```vue + +``` + +- [ ] **Step 3: Run lint + typecheck** + +```bash +cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck +``` +Expected: 0 errors + +- [ ] **Step 4: Commit** + +```bash +cd Inventory_frontend +git add app/components/StructureNodeEditor.vue app/composables/useStructureNodeCrud.ts +git commit -m "feat(piece) : add quantity input to composant structure editor" +``` + +--- + +### Task 6: Machine Detail Page — Display Quantity + +**Files:** +- Modify: `Inventory_frontend/app/components/PieceItem.vue` + +**Context:** `PieceItem.vue` renders each piece in the machine structure view. The piece name is displayed at line ~26 in an `