# 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: `frontend/app/shared/types/inventory.ts`
- Modify: `frontend/app/shared/model/componentStructure.ts`
- Modify: `frontend/app/shared/model/componentStructureSanitize.ts`
- Modify: `frontend/app/shared/model/componentStructureHydrate.ts`
- Modify: `frontend/app/shared/utils/structureAssignmentHelpers.ts`

- [ ] **Step 1: Add `quantity` to `ComponentModelPiece` type**

In `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 `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 `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 `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 `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 `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 frontend && npm run lint:fix && npx nuxi typecheck
```
Expected: 0 errors

- [ ] **Step 8: Commit**

```bash
cd 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: `frontend/app/components/StructureNodeEditor.vue` (piece section, lines ~229-299)
- Modify: `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 `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 `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
<input
  v-model.number="piece.quantity"
  type="number"
  :min="1"
  step="1"
  placeholder="Qté"
  class="input input-bordered input-sm md:input-md w-20"
  @input="piece.quantity = Math.max(1, piece.quantity || 1)"
/>
```

- [ ] **Step 3: Run lint + typecheck**

```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
Expected: 0 errors

- [ ] **Step 4: Commit**

```bash
cd 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: `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 `<h3>` tag. Quantity should appear as "×N" after the name, in secondary text. For direct pieces (no parent component), it should be editable. For composant pieces, read-only.

- [ ] **Step 1: Add quantity display to PieceItem**

In `frontend/app/components/PieceItem.vue`, after the piece name in the `<h3>` tag (line ~26), add the quantity display:

```vue
<span
  v-if="displayQuantity > 1"
  class="text-sm font-normal text-base-content/60 ml-1"
>
  ×{{ displayQuantity }}
</span>
```

Add to the component's setup:

```typescript
const displayQuantity = computed(() => {
  return props.piece.quantity ?? 1
})
```

- [ ] **Step 2: Add editable quantity for direct machine pieces**

For pieces directly on a machine (no `parentComponentLinkId`), add an editable quantity input in the piece's edit section, following the pattern of existing override fields (nameOverride, referenceOverride, prixOverride). Place it alongside the overrides form:

```vue
<div v-if="!piece.parentComponentLinkId && isEditMode" class="form-control">
  <label class="label">
    <span class="label-text text-sm">Quantité</span>
  </label>
  <input
    v-model.number="pieceData.quantity"
    type="number"
    min="1"
    step="1"
    class="input input-bordered input-sm md:input-md w-24"
  />
</div>
```

Add `quantity` to the `pieceData` reactive object (line ~270-275):

```typescript
quantity: props.piece.quantity ?? 1,
```

Ensure this value is included in the data emitted when saving (follow the same pattern as `nameOverride`, `referenceOverride`, `prixOverride` in the save/emit logic).

- [ ] **Step 3: Run lint + typecheck**

```bash
cd frontend && npm run lint:fix && npx nuxi typecheck
```
Expected: 0 errors

- [ ] **Step 4: Commit**

```bash
cd frontend
git add app/components/PieceItem.vue
git commit -m "feat(piece) : display and edit quantity on machine piece items"
```

---

### Task 7: Submodule Update + Final Verification

**Files:**
- Update submodule pointer in main repo

- [ ] **Step 1: Push frontend commits**

```bash
cd frontend && git push
```

- [ ] **Step 2: Update submodule pointer in main repo**

```bash
cd /home/matthieu/dev_malio/Inventory
git add frontend
git commit -m "chore(frontend) : update submodule — piece quantity feature"
```

- [ ] **Step 3: Run all backend tests**

Run: `make test`
Expected: All tests pass

- [ ] **Step 4: Run migration on dev database**

```bash
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
```

- [ ] **Step 5: Manual smoke test**

1. Open a composant edit page → verify quantity input appears on each piece in structure
2. Set quantity to 4, save → reload → verify quantity persisted
3. Open a machine with that composant → verify "×4" appears next to piece name (read-only)
4. Add a piece directly to a machine → verify quantity input appears in edit mode
5. Set quantity to 3, save → verify "×3" appears
6. Clone the machine → verify cloned pieces have same quantities
