docs : add piece quantity implementation plan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-12 11:56:24 +01:00
parent b8edf1ea95
commit 233ee3faf3

View File

@@ -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
<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 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 `<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 `Inventory_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 Inventory_frontend && npm run lint:fix && npx nuxi typecheck
```
Expected: 0 errors
- [ ] **Step 4: Commit**
```bash
cd Inventory_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 Inventory_frontend && git push
```
- [ ] **Step 2: Update submodule pointer in main repo**
```bash
cd /home/matthieu/dev_malio/Inventory
git add Inventory_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