16 KiB
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):
#[ORM\Column(type: Types::INTEGER, options: ['default' => 1])]
#[Assert\GreaterThanOrEqual(1)]
private int $quantity = 1;
Add the import at the top if not present:
use Symfony\Component\Validator\Constraints as Assert;
Add getter/setter after existing methods (before closing brace):
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:
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
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':
'quantity' => $this->resolvePieceQuantity($link),
Add a new private method after normalizePieceLinks():
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):
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
quantitynot in payload: preserves existing value -
If
quantityin 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):
$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
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:
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:
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)
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
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
quantitytoComponentModelPiecetype
In frontend/app/shared/types/inventory.ts, add quantity to the ComponentModelPiece interface (after role, line ~23):
quantity?: number
- Step 2: Add
quantitytovalidatePiece()in same file
In frontend/app/shared/types/inventory.ts, in validatePiece() (line ~144-172):
After const role = ensureString(value.role) (line ~161), add:
const quantity = typeof value.quantity === 'number' && value.quantity >= 1 ? value.quantity : undefined
And in the return object, add after the role spread:
...(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:
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):
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:
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()andmapComponentPieces()to preserve quantity
In frontend/app/shared/model/componentStructureHydrate.ts:
In hydratePieces() (line ~95-107), add to the mapped object:
...(piece?.quantity !== undefined && piece.quantity >= 1 ? { quantity: piece.quantity } : {}),
In mapComponentPieces() (line ~168-179), add to the mapped object:
...(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:
quantity: typeof (definition as any).quantity === 'number' ? (definition as any).quantity : null,
- Step 7: Run lint + typecheck
cd frontend && npm run lint:fix && npx nuxi typecheck
Expected: 0 errors
- Step 8: Commit
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:
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:
<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
cd frontend && npm run lint:fix && npx nuxi typecheck
Expected: 0 errors
- Step 4: Commit
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:
<span
v-if="displayQuantity > 1"
class="text-sm font-normal text-base-content/60 ml-1"
>
×{{ displayQuantity }}
</span>
Add to the component's setup:
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:
<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):
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
cd frontend && npm run lint:fix && npx nuxi typecheck
Expected: 0 errors
- Step 4: Commit
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
cd frontend && git push
- Step 2: Update submodule pointer in main repo
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
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
- Step 5: Manual smoke test
- Open a composant edit page → verify quantity input appears on each piece in structure
- Set quantity to 4, save → reload → verify quantity persisted
- Open a machine with that composant → verify "×4" appears next to piece name (read-only)
- Add a piece directly to a machine → verify quantity input appears in edit mode
- Set quantity to 3, save → verify "×3" appears
- Clone the machine → verify cloned pieces have same quantities