Pack Operations Troubleshooting Runbook
Operational runbook for diagnosing pack/unpack and pack-family link failures in:
apps/backend/src/pack-operations/apps/backend/src/product-pack-links/
Use this with the domain overview in Pack Hierarchy.
Scope
Primary codepaths:
pack-operations/application/pack-operations.service.tspack-operations/interfaces/pack-operations.controller.tspack-operations/infrastructure/pack-operations.repository.tsproduct-pack-links/application/product-pack-links.service.tsproduct-pack-links/interfaces/product-pack-links.controller.tsproduction-runs/application/production-runs.service.ts(repackaging completion pipeline)
Pack/unpack is application orchestration over an existing production-run stock pipeline. Direction (pack vs unpack) is derived from base-unit factors — clients never choose it.
1) Symptom triage
| Symptom | Likely layer |
|---|---|
400 quantity must be greater than 0 | Client request body |
400 fromVariantId and toVariantId must be different | Client selected the same pack level twice |
400 Both fromVariantId and toVariantId must be levels of this product's pack family | Variants are not linked in one linear family |
400 Insufficient available stock | Available-stock guard (on-hand minus reserved/outgoing) |
400 with conversion/base-unit message | Non-integer or invalid factor relationship between levels |
409 A pack/unpack operation with this idempotency key is already in progress | Duplicate in-flight request with same idempotencyKey |
| Idempotent retry returns original result | Expected: completed pack_operation row found |
404 No pack link found between the specified parent and child levels | insert-between target link missing |
409 The middle variant is already part of this/another pack family | insert-between middle variant already linked |
| Family stock rollup looks wrong | Caller summed raw on-hand across levels instead of getFamilyStockRollup |
2) Pack/unpack request checklist
Expected request (POST /pack-operations/pack-unpack, PackUnpackDTO):
{
"businessId": "<uuid>",
"productId": "<uuid>",
"locationId": "<uuid>",
"fromVariantId": "<source-level-uuid>",
"toVariantId": "<target-level-uuid>",
"quantity": 1,
"createdBy": "<user-uuid>",
"idempotencyKey": "<client-unique-key>",
"lotNumber": "LOT-2026-001",
"expirationDate": "2027-01-01T00:00:00.000Z"
}
Validate before deeper debugging:
- Both variants belong to the same product's resolved pack family (
GET /product-pack-links/products/:productId). quantityis positive and refers to units offromVariantId, not the target level.idempotencyKeyis unique per logical operator action (reuse only for retries of the same action).- Location has available stock on the source variant after subtracting reserved and in-transit-outgoing quantities.
Direction is computed in resolvePackDirection from baseUnitsPerVariant:
- consuming a larger pack into smaller levels → unpack
- consuming smaller levels into a larger pack → pack
3) Idempotency recovery
PackOperationsService.packUnpack inserts a pending pack_operation row keyed by (businessId, idempotencyKey).
| State | Behavior |
|---|---|
| First request | Inserts pending row, runs conversion, marks completed with productionRunId |
| Retry after success | Returns the original result reconstructed from the completed production run |
| Retry while in progress | 409 Conflict — ask the client to retry shortly |
| Validation/runtime failure | Pending row is deleted so a corrected retry with the same key is not permanently blocked |
If a client is stuck on 409:
- Wait briefly and retry with the same key (covers in-flight completion).
- Confirm whether a
repackagingproduction run was created for that attempt. - Only generate a new
idempotencyKeywhen starting a new logical conversion, never to force a second apply of the same action.
4) Insufficient stock
The available-stock guard uses:
available = onHand - reservedStock - inTransitOutgoing
Common false positives:
- stock exists on a sibling pack level (for example units exist but the case level is empty)
- reserved stock from open sales/orders reduces availability
- outgoing transfer quantity is committed and not available to convert
- inventory row for the variant is missing at that location (treated as available
0)
Checks:
- Inspect per-level stock with
GET /product-pack-links/products/:productId/stock-rollup. - Confirm the client is converting from the level that actually has available quantity.
- Unpack a larger pack first when only the parent level has stock.
5) Family link and insert-between failures
Create/update/delete links
Use POST /product-pack-links for a new adjacent link, PATCH to change unitsPerParent, and DELETE to remove a link (may split the family).
Family validation requires a strict linear chain. Branching structures (one parent with two children, or cycles) are rejected by resolveChainOrThrow.
Insert middle level
Use POST /product-pack-links/insert-between when splitting an existing parent→child link into parent→middle→child.
| Response | Meaning | Fix |
|---|---|---|
404 | No link between parentVariantId and childVariantId | Create the adjacent link first, or correct the variant IDs |
409 middle already in this family | Middle variant already appears in an existing link | Choose a different middle variant |
409 middle in another family | Middle variant linked under a different product/family | Unlink it first or use another variant |
400 factor does not divide cleanly into a positive middle→child factor | unitsPerParent cannot derive a valid middle→child factor from the existing link | Adjust unitsPerParent so oldUnits / unitsPerParent is a positive finite factor |
When to use which API:
| Goal | API |
|---|---|
| Connect two currently unlinked adjacent levels | POST /product-pack-links |
| Insert a new level into an existing adjacent link | POST /product-pack-links/insert-between |
| Change how many children make one parent | PATCH /product-pack-links/:id |
| Convert on-hand stock between levels | POST /pack-operations/pack-unpack |
6) Cost / WAC surprises
Pack/unpack reuses the production-run completion path:
- Consume source stock at the source WAC (
cost / GREATEST(quantity, 1)). - Produce target stock at a converted unit cost that conserves total inventory value.
- Round only after
quantity × unitCostin the shared production-run pipeline.
If totals drift after many conversions:
- Confirm both sides used the same location.
- Confirm no concurrent manual adjustments changed source WAC mid-flight.
- Prefer diagnosing through the created
repackagingproduction run rather than inventing a parallel cost formula.
Do not sum raw on-hand quantities across pack levels for valuation. Use getFamilyStockRollup (or an equivalent multiply-once-by-base_units_per_variant rule) when reporting family totals.