Saltar al contenido principal

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

ConceptDescription
Pack levelA 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 familyThe 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 unitThe smallest level in a family (level_order = 0). Its base_units_per_variant is always 1.
units_per_parentStored 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_variantA 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 runA production_run of type repackaging — the mechanism that actually moves stock between two pack levels. See Production Runs.
Idempotency keyA client-supplied key on every pack/unpack request. Deduplicates retried requests so a network retry never double-applies a stock conversion.

Data Model

One row per adjacent level pair in a family.

ColumnTypeNotes
iduuidPK
business_iduuidTenant scope
product_iduuidThe parent product owning the whole family
parent_variant_iduuidThe larger pack (e.g. Case)
child_variant_iduuidThe next level down (e.g. 6-pack)
units_per_parentnumeric(20,4)Children per parent
level_orderintegerThe 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, so base_units_per_variant is 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.

ColumnNotes
idempotency_keyClient-supplied. Unique per (business_id, idempotency_key).
statuspendingcompleted (or the row is deleted on failure, so a legitimate retry can proceed).
production_run_idSet once the underlying repackaging run completes.
directionpack 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():

  1. Resolves the product's pack family and locates the from/to levels.
  2. Derives direction from which level has the larger base_units_per_variant (never chosen by the caller) — see resolvePackDirection in build-pack-cost-split.ts.
  3. Enforces the available-stock-only guard: available = quantity − reserved_stock − in_transit_outgoing. Pack/unpack never consumes reserved or already-committed-outgoing stock.
  4. 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).
  5. Creates a production_run (type repackaging, one input line, one output line — with lot/expiry as inventory_detail payloads on the output line if provided) and immediately completes it, reusing ProductionRunsService.completeProductionRun end to end: stock check → decrease/increase inventory → WAC update → ledger rows → OnProductionRunCompletedEvent.
  6. Records the idempotency key in pack_operation so 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 by variant_id.
  • collections.repository.ts (countProductStock) — the "remaining stock" warning shown when archiving a collection. Fixed to weight each row by base_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

MethodPathDescription
GET/product-pack-links/products/:productIdResolve a product's pack family (ordered levels + factors)
GET/product-pack-links/products/:productId/stock-rollupPer-level on-hand stock + aggregated base-unit total at one location
GET/product-pack-links/products/:productId/linksRaw (unordered) link rows for a product
POST/product-pack-linksCreate a link between two variants
POST/product-pack-links/insert-betweenInsert a middle pack level into an existing parent→child link
PATCH/product-pack-links/:idUpdate a link's unitsPerParent
DELETE/product-pack-links/:idDelete 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:

  1. Finds the existing parent→child link.
  2. Derives the middle→child factor as oldUnitsPerParent / unitsPerParent.
  3. Deletes the old parent→child link and creates parent→middle and middle→child in one transaction.
  4. Resyncs base_units_per_variant for 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 (404 otherwise)
  • middle must not already belong to this or another pack family (409 Conflict)
  • unitsPerParent must 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

MethodPathDescription
POST/pack-operations/pack-unpackConvert 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

FilePurpose
apps/frontend-pwa/src/components/forms/product/ProductPackLevelsTab.tsxProduct-editor tab: define/edit/remove pack levels
apps/frontend-pwa/src/components/pack-operations/PackUnpackDialog.tsxPack/unpack action dialog
apps/frontend-pwa/src/components/pack-operations/PackFamilyStockCard.tsxPer-level stock + rollup total, with a one-click path into the unpack dialog
apps/frontend-pwa/src/hooks/usePackLinks.ts, usePackOperations.tsTanStack Query hooks
apps/frontend-pwa/src/services/packLinkService.ts, packOperationService.tsAPI 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 real inventory.quantity, updated via InventoriesService.processCreateInventoryDetailEventincreaseQuantityInventoryDetail) 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-scoped increaseInventory/decreaseInventory path), but it's a latent bug worth fixing separately.
  • discovery.repository.ts's per-location availability lookup builds a Map keyed only by locationId from inventory rows scoped to a productId — for any multi-variant product with more than one variant stocked at the same location, only the last-processed variant's row survives the Map construction (silent data loss, not double-counting). Pre-existing, not specific to pack levels; not fixed in this pass.
  • low-stock-alerts is not variant-aware at all (no variant_id anywhere 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.