Pack Hierarchy
Overview
Some businesses buy, stock, and sell the same product at several pack levels — e.g. a Case of 24 that contains four 6-packs, each holding 6 individual Units — and physically pack/unpack between levels (break a case into 6-packs, reassemble 6-packs into a case). A pharmacy blister pack (a sealed sheet of N doses, tracked by lot and expiration) is the same shape.
This feature lets a product expose multiple pack levels, each with its own on-hand stock, barcode, price, and lot/expiry, and supports a pack/unpack operation that converts stock between levels while conserving total inventory value.
Key design decision: each pack level is a product_variant of one parent product — not a separate product. This reuses the variantId plumbing already threaded through inventory, purchasing, sales, transfers, counts, adjustments, and the production-run engine (see Product Variants), instead of adding a parallel stock-tracking mechanism.
Domain Concepts
| Concept | Description |
|---|---|
| Pack level | A product_variant that represents one buy/stock/sell granularity of a product (e.g. Case, 6-pack, Unit). Each level has its own SKU, barcode, price, and independent on-hand count in inventory. |
| Pack family | The ordered chain of pack levels for one product, from the base unit up to the largest pack. Modeled as a strict linear chain — every level has at most one parent and at most one child — never a branching tree. |
| Base unit | The smallest level in a family (level_order = 0). Its base_units_per_variant is always 1. |
units_per_parent | Stored per link: how many of the child level make up one of the parent level (e.g. 4 six-packs per case). |
base_units_per_variant | A derived, cached factor on product_variant: how many base units one of this level equals. Computed by walking units_per_parent down the chain; always recomputed from the link chain, never hand-edited. |
| Repackaging run | A production_run of type repackaging — the mechanism that actually moves stock between two pack levels. See Production Runs. |
| Idempotency key | A client-supplied key on every pack/unpack request. Deduplicates retried requests so a network retry never double-applies a stock conversion. |
Data Model
product_pack_link
One row per adjacent level pair in a family.
| Column | Type | Notes |
|---|---|---|
id | uuid | PK |
business_id | uuid | Tenant scope |
product_id | uuid | The parent product owning the whole family |
parent_variant_id | uuid | The larger pack (e.g. Case) |
child_variant_id | uuid | The next level down (e.g. 6-pack) |
units_per_parent | numeric(20,4) | Children per parent |
level_order | integer | The child's level order in the resolved chain (0 = base). The top-most level has no link row of its own — it's never a child_variant_id. |
Constraints that enforce the strict-chain invariant:
- Unique
(business_id, parent_variant_id, child_variant_id)— no duplicate edges. - Unique
(business_id, child_variant_id)— a variant has at most one parent. - Unique
(business_id, parent_variant_id)— a variant has at most one child. Together with the previous constraint, this guarantees the family is a linear chain, never a branching tree, sobase_units_per_variantis always a single well-defined value. units_per_parent > 0;parent_variant_id <> child_variant_id.
Migration: packages/backend/database/src/migrations/2026-07-14t10-00-00-product-pack-levels.mjs.
product_variant.base_units_per_variant
Nullable numeric(20,4) column added to product_variant. NULL = not part of any pack family (a plain/base variant). Recomputed by ProductPackLinksService inside the same transaction as every link create/update/delete — the link chain is always the source of truth; this column is a read-optimization cache, never authored directly.
product.has_variants (pack families)
Pack-link create/update (and resync after partial delete) sets product.has_variants = true in the same transaction as syncFamilyState. POS and sale Search Products open the variant/pack-level picker only when this flag is true. The flag is not flipped back to false when the last pack link is deleted (option-group variants or remaining level SKUs may still exist).
Backfill migration: 2026-07-14t20-00-00-backfill-has-variants-for-packs.mjs (idempotent: pack-link products + products with more than one product_variant).
inventory_detail.variant_id
Nullable FK added so lot/batch/serial tracking is scoped per pack level, not pooled across a product's variants. Migration: 2026-07-14t12-00-00-add-variant-id-to-inventory-detail.mjs. Before this migration, inventory_detail had no variant scoping at all — batches for different variants (or different pack levels) of the same product could collide. All lookup methods (getBySerialNumber, getByBatchNumber, getBySerialNumberAndBatchNumber) now take an optional variantId and match variant_id IS NULL explicitly when omitted, rather than matching any variant.
pack_operation
The idempotency ledger for pack/unpack requests.
| Column | Notes |
|---|---|
idempotency_key | Client-supplied. Unique per (business_id, idempotency_key). |
status | pending → completed (or the row is deleted on failure, so a legitimate retry can proceed). |
production_run_id | Set once the underlying repackaging run completes. |
direction | pack or unpack, for building a cached result on retry. |
Migration: 2026-07-14t11-30-00-pack-operations.mjs.
production_run_type = 'repackaging'
Added to the existing enum (2026-07-14t11-00-00-add-repackaging-production-run-type.mjs, and the matching ProductionRunType.REPACKAGING in packages/global/enums/production-run.enums.ts). Postgres cannot drop a single enum value, so the migration's down is a documented no-op.
Architecture
apps/backend/src/
├── product-pack-links/ # Structural model: links, resolved family, stock rollup
│ ├── domain/
│ │ ├── pack-family.domain.ts # PURE chain-resolution math (no I/O)
│ │ └── product-pack-links-repository.domain.ts
│ ├── infrastructure/
│ │ └── product-pack-links.repository.ts
│ ├── application/
│ │ └── product-pack-links.service.ts # CRUD, cache/level-order sync, rollup
│ └── interfaces/
│ ├── product-pack-links.controller.ts
│ ├── dtos/
│ └── query/
│
├── pack-operations/ # The actual pack/unpack stock mutation
│ ├── domain/
│ │ └── pack-operations-repository.domain.ts
│ ├── infrastructure/
│ │ └── pack-operations.repository.ts # pack_operation idempotency ledger
│ ├── application/
│ │ ├── build-pack-cost-split.ts # PURE conversion + cost-split math (no I/O)
│ │ └── pack-operations.service.ts # Orchestrates: resolve family → guard
│ │ # available stock → drive production-runs
│ └── interfaces/
│ ├── pack-operations.controller.ts
│ └── dtos/
│
└── production-runs/ # Unchanged engine — one validation branch added
└── application/
└── production-runs.service.ts # validatePackFamily() for repackaging runs
Dependencies: pack-operations imports product-pack-links and production-runs. production-runs imports product-pack-links (for the validation branch). No circular imports.
Pack/Unpack Semantics
Pack/unpack is implemented as a specialized production run — no new stock-movement, ledger, or WAC code was added. PackOperationsService.packUnpack():
- Resolves the product's pack family and locates the
from/tolevels. - Derives direction from which level has the larger
base_units_per_variant(never chosen by the caller) — seeresolvePackDirectioninbuild-pack-cost-split.ts. - Enforces the available-stock-only guard:
available = quantity − reserved_stock − in_transit_outgoing. Pack/unpack never consumes reserved or already-committed-outgoing stock. - Computes the output quantity and a conserving unit cost (
build-pack-cost-split.ts, pure functions, unit-tested for exact value conservation across repeated pack↔unpack cycles). - Creates a
production_run(typerepackaging, one input line, one output line — with lot/expiry asinventory_detailpayloads on the output line if provided) and immediately completes it, reusingProductionRunsService.completeProductionRunend to end: stock check → decrease/increase inventory → WAC update → ledger rows →OnProductionRunCompletedEvent. - Records the idempotency key in
pack_operationso a retried request returns the original result instead of double-applying.
Why repackaging bypasses the normal inventory-type guard
ProductionRunsService.completeProductionRun normally requires inputs to be raw_material/component/packaging and outputs to be finished_good. Pack levels are all finished_good, so a repackaging run would always fail that check. Instead, validatePackFamily() (branched on productionRunType === 'repackaging') requires every input/output line to carry a variantId, all of them to resolve to the same product's pack family, and the run to conserve total base units (Σ input qty × base_units_per_variant == Σ output qty × base_units_per_variant, within a small tolerance).
Cost conservation
inventory.cost is a running total value; WAC = cost / quantity. Splitting or merging a pack level must never create or destroy value:
- Unpack 1 Case (WAC = $24) into 4 Six-packs:
sixPackUnitCost = caseWAC / 4. - Pack 4 Six-packs (WAC = $6 each) into 1 Case:
caseUnitCost = Σ consumed six-pack values.
The computed unit cost is deliberately not pre-rounded — the production-run pipeline multiplies quantity × unitCost and rounds that total to 6dp, matching every other cost calculation in the codebase. Keeping full precision upstream is what makes the total reconcile exactly instead of leaking a rounding remainder across repeated cycles. See build-pack-cost-split.spec.ts for a 50-cycle round-trip test asserting zero drift.
Stock Rollup Rule
ProductPackLinksService.getFamilyStockRollup(productId, locationId, businessId) is the single source of truth for "total units available" across a pack family. It multiplies each level's on-hand quantity by that level's base_units_per_variant exactly once, then sums:
totalBaseUnits = Σ (level.onHandQuantity × level.baseUnitsPerVariant)
Never sum raw on-hand quantities across levels directly — a Case and its Units are not additive (quantity=2 cases + quantity=3 units ≠ 5 of anything meaningful). Any new report, dashboard query, or aggregation that touches a product with pack levels must use this rollup (or replicate its exact multiply-once rule) instead of a plain SUM(inventory.quantity) grouped by product_id.
Known double-counting bugs fixed during this audit
Two pre-existing queries pooled inventory.quantity grouped only by product_id (not variant_id), which silently mixed incompatible units for any multi-variant product and is especially wrong for pack families:
markdowns.repository.ts(findExportEnrichmentData) — the markdown-wave CSV export's "on hand" / value columns. Fixed to group byvariant_id.collections.repository.ts(countProductStock) — the "remaining stock" warning shown when archiving a collection. Fixed to weight each row bybase_units_per_variant(defaulting to 1 for non-pack variants) before summing.
If you add a new report or export that aggregates inventory.quantity for a product, scope it by variant_id (or weight by base_units_per_variant when intentionally summing across levels) — do not group by product_id alone.
API Endpoints
product-pack-links
| Method | Path | Description |
|---|---|---|
GET | /product-pack-links/products/:productId | Resolve a product's pack family (ordered levels + factors) |
GET | /product-pack-links/products/:productId/stock-rollup | Per-level on-hand stock + aggregated base-unit total at one location |
GET | /product-pack-links/products/:productId/links | Raw (unordered) link rows for a product |
POST | /product-pack-links | Create a link between two variants |
POST | /product-pack-links/insert-between | Insert a middle pack level into an existing parent→child link |
PATCH | /product-pack-links/:id | Update a link's unitsPerParent |
DELETE | /product-pack-links/:id | Delete a link (splits the family if it's an interior link) |
Insert a middle level (POST /product-pack-links/insert-between)
Use this when a product already has a two-level family (for example Case → Unit) and you need to insert a middle variant (Six-pack) without deleting and recreating the chain.
Application behavior in ProductPackLinksService.insertLevel:
- Finds the existing parent→child link.
- Derives the middle→child factor as
oldUnitsPerParent / unitsPerParent. - Deletes the old parent→child link and creates parent→middle and middle→child in one transaction.
- Resyncs
base_units_per_variantfor the resolved family.
Request body (InsertPackLevelDTO):
{
"businessId": "<uuid>",
"productId": "<uuid>",
"parentVariantId": "<case-variant-uuid>",
"childVariantId": "<unit-variant-uuid>",
"middleVariantId": "<six-pack-variant-uuid>",
"unitsPerParent": 4,
"createdBy": "<user-uuid>"
}
Constraints verified in source:
- parent, child, and middle must be three distinct variants of the same product
- a pack link must already exist between parent and child (
404otherwise) - middle must not already belong to this or another pack family (
409 Conflict) unitsPerParentmust be positive and must divide the existing link factor into a positive middle→child factor
Prefer insert-between over delete+create when stock already exists at either end of the link — the service preserves the family identity and recomputes base-unit factors in place.
pack-operations
| Method | Path | Description |
|---|---|---|
POST | /pack-operations/pack-unpack | Convert stock between two levels of the same pack family. Idempotent on idempotencyKey. |
For pack/unpack failure modes, stock guards, and idempotency recovery, see Pack Operations Troubleshooting.
PWA Integration
| File | Purpose |
|---|---|
apps/frontend-pwa/src/components/forms/product/ProductPackLevelsTab.tsx | Product-editor tab: define/edit/remove pack levels |
apps/frontend-pwa/src/components/pack-operations/PackUnpackDialog.tsx | Pack/unpack action dialog |
apps/frontend-pwa/src/components/pack-operations/PackFamilyStockCard.tsx | Per-level stock + rollup total, with a one-click path into the unpack dialog |
apps/frontend-pwa/src/hooks/usePackLinks.ts, usePackOperations.ts | TanStack Query hooks |
apps/frontend-pwa/src/services/packLinkService.ts, packOperationService.ts | API client functions |
Pack levels are plain product_variant rows, so they already appear in the shared variant pickers (VariantPickerSheet.tsx, used by sales, purchase orders, transfers, adjustments, production runs; POVariantPicker.tsx, used by purchase orders) with no changes needed there — except a label-fallback fix: pack-level variants have no option-type/value assignments, so variantLabel comes back as an empty string rather than null/undefined from the backend. Both pickers previously used variantLabel ?? sku, which doesn't catch an empty string; fixed to variantLabel || sku.
Catalog hygiene (duplicate product names)
Sale Search Products filters to inventoryType=finished_good and isActive=true. That reduces noise but does not merge or delete duplicate catalog rows. Use this read-only SQL to list same-name active finished goods for merchant review (do not auto-delete):
SELECT
p.business_id,
lower(trim(p.name)) AS normalized_name,
count(*) AS product_count,
array_agg(p.id ORDER BY p.created_at) AS product_ids,
array_agg(p.sku ORDER BY p.created_at) AS skus
FROM product p
WHERE p.is_active = true
AND p.inventory_type = 'finished_good'
GROUP BY p.business_id, lower(trim(p.name))
HAVING count(*) > 1
ORDER BY product_count DESC, normalized_name;
Known Limitations / Follow-up Work
- No automatic POS "out of stock but a case is available — unpack?" prompt. The sale-line add-item flow (
AddItemSectionWithPricing, shared between retail sales and restaurant orders) has no existing stock-sufficiency check to extend safely. The unpack dialog is one click away from the product's Inventory tab instead. Wiring an automatic prompt into the shared add-item flow is a reasonable follow-up, but a deliberately separate, larger change. quantity_inventory_detail(a separate reconciliation counter from the realinventory.quantity, updated viaInventoriesService.processCreateInventoryDetailEvent→increaseQuantityInventoryDetail) has no variant scoping at all. It updates every variant row matching a bare(location_id, product_id)pair. This predates pack hierarchy and doesn't affect pack/unpack's actual stock quantity (which goes through the correctly variant-scopedincreaseInventory/decreaseInventorypath), but it's a latent bug worth fixing separately.discovery.repository.ts's per-location availability lookup builds aMapkeyed only bylocationIdfrominventoryrows scoped to aproductId— for any multi-variant product with more than one variant stocked at the same location, only the last-processed variant's row survives theMapconstruction (silent data loss, not double-counting). Pre-existing, not specific to pack levels; not fixed in this pass.low-stock-alertsis not variant-aware at all (novariant_idanywhere in the module). Low-stock alerting currently operates at the product level only. Adding variant/pack-level support there is a larger, separate feature gap.