@happyvertical/smrt-manufacturing

Bills of materials, cost rollup, requirements explosion, and production-order consume / produce operations. Strictly industry-neutral — recipe-to-finished-goods for any vertical.

v0.29.34DomainManufacturingBOMMulti-tenant

Overview

@happyvertical/smrt-manufacturing turns a recipe into finished goods. It owns two models — BillOfMaterials (a versioned recipe for one product) and BomLine (one component, with waste) — and three services that plan and execute a build. It is industry-neutral: the same primitives serve apparel, furniture, automotive, CPG, electronics, food production, or custom hardware.

The package never invents its own stock store — it mutates the smrt-inventory ledger on a production order's behalf, and the ProductionOrder row itself lives in smrt-commerce as a Contract subtype. This package supplies the BOM math and the consume / produce operations.

Installation

bash
pnpm add @happyvertical/smrt-manufacturing

Depends on @happyvertical/smrt-inventory (peer-installed via your workspace) for the stock operations it drives.

Define a BOM with components

A BillOfMaterials has a draft / active / superseded lifecycle; each BomLine carries a qtyPerUnit and an optional wastePercent. The line's effectiveQtyPerUnit() returns the quantity including waste, and every planning method works from that effective number.

typescript
import {
  BillOfMaterialsCollection,
  BomLineCollection,
} from '@happyvertical/smrt-manufacturing';

const db = { type: 'sqlite', url: 'app.db' };
const boms = await BillOfMaterialsCollection.create({ db });
const lines = await BomLineCollection.create({ db });

const bom = await boms.create({
  productId: shirt.id, // upstream Product or any STI subtype
  version: 1,
  status: 'active',
  currency: 'USD',
});
await bom.save();

const fabric = await lines.create({
  bomId: bom.id,
  componentSkuId: fabricSku.id,
  qtyPerUnit: 2.0,
  uom: 'yards',
  wastePercent: 10, // 10% cutting waste
});
await fabric.save();

const buttons = await lines.create({
  bomId: bom.id,
  componentSkuId: buttonSku.id,
  qtyPerUnit: 4,
  uom: 'each',
});
await buttons.save();

Roll up material cost with waste

computeMaterialCost(bomId) walks every line, resolves each component's unit cost through a pluggable costResolver, applies waste, and returns the rolled-up cost per produced unit plus a per-line breakdown. Cost resolution is intentionally external — point it at smrt-products Material.costPerUnit, a rolling average, or a price book:

typescript
import { BomService } from '@happyvertical/smrt-manufacturing';

const service = await BomService.create({
  db,
  // Plug in any cost source: smrt-products Material.costPerUnit, a
  // purchase-order rolling average, a vendor price book, anything.
  // Returning null/undefined marks the line costUnavailable.
  costResolver: async (componentSkuId) => {
    const sku = await skus.get(componentSkuId);
    return sku ? Number(JSON.parse(sku.attributes ?? '{}').cost ?? 0) : null;
  },
});

const rollup = await service.computeMaterialCost(bom.id);
console.log(rollup.totalCost, rollup.currency);
// rollup also carries lineBreakdown[] (per-line cost incl. waste) and
// hasMissingCosts so a UI can warn when totalCost is a lower bound.

The returned BomCostRollup shape:

FieldTypeDescription
bomIdstringThe BOM that was rolled up.
totalCostnumberTotal material cost per produced unit.
currencystringISO 4217 code; defaults to 'USD'.
lineBreakdownBomLineCost[]Per line: qtyPerUnit, wastePercent, effectiveQty, unitCost, lineCost, uom, costUnavailable.
hasMissingCostsbooleantrue when any line's cost was unresolved; treat totalCost as a lower bound.

Plan a production run

explodeRequirements(bomId, qty) returns a deduplicated shopping list (lines pointing at the same component SKU are summed), and canProduce(bomId, qty) checks it against available stock summed across every location. Neither mutates anything:

typescript
// Shopping list to build 100 units (waste included, duplicate SKUs summed).
const requirements = await service.explodeRequirements(bom.id, 100);
// [{ componentSkuId: fabricSku.id, totalQty: 220, uom: 'yards' },
//  { componentSkuId: buttonSku.id, totalQty: 400, uom: 'each' }]

// Do we have enough available stock right now (summed across locations)?
const check = await service.canProduce(bom.id, 100);
if (!check.ok) {
  for (const shortage of check.shortages) {
    console.log(
      `Need ${shortage.requested} of ${shortage.componentSkuId}, have ${shortage.available}`,
    );
  }
}

canProduce returns a discriminated CanProduceResult: { ok: true; shortages: [] } or { ok: false; shortages: MaterialShortage[] }, where each shortage carries componentSkuId, requested, and available.

Execute consume / produce

ProductionService is the operational surface — it writes stock movements against the inventory ledger. Locations and the finished SKU are passed explicitly (they are not stored on the order), so one productId can produce into many SKUs:

typescript
import { ProductionService } from '@happyvertical/smrt-manufacturing';

const production = await ProductionService.create({ db });

// Pull materials from the factory (consume -> wip / out of available).
const consumed = await production.consumeMaterials(
  { id: order.id, productId: order.productId }, // ProductionOrder
  { locationId: factory.id, qty: 100 },         // explicit location
);

// Receive finished goods (+qty available for the finished SKU).
const produced = await production.produceFinishedGoods(
  { id: order.id, productId: order.productId },
  {
    locationId: factory.id,
    qty: 100,
    finishedSkuId: finishedVariant.id, // one productId can have many SKUs
  },
);

// Every emitted StockMovement is stamped sourceType: 'ProductionOrder'
// + sourceId: order.id, so you can reconstruct it later via
// StockMovementCollection.findBySource('ProductionOrder', order.id).

Opt-in DispatchBus wiring

The package ships handlers that bridge production-order lifecycle events to the consume / produce flow. They are off by default; install them explicitly alongside the inventory handlers. The companion contract:created and fulfillment:shipped handlers live in smrt-inventory.

typescript
import { createDispatchBus } from '@happyvertical/smrt-core';
import { installInventoryDispatchHandlers } from '@happyvertical/smrt-inventory';
import { installManufacturingDispatchHandlers } from '@happyvertical/smrt-manufacturing';

const bus = await createDispatchBus({ db });

// Inventory handlers bridge contract:created and fulfillment:shipped.
await installInventoryDispatchHandlers({ dispatchBus: bus, db });

// Manufacturing handlers bridge production_order:posted (and optionally
// production_order:completed) to consume / produce.
await installManufacturingDispatchHandlers({
  dispatchBus: bus,
  db,
  // Consume AND produce in one shot when posted (make-to-stock).
  producedOnPosted: true,
  // Per-leg toggles: installProductionPosted, installProductionCompleted.
});

// Later, when a production order is posted:
await bus.emit('production_order:posted', {
  productionOrderId: order.id,
  productId: order.productId,
  locationId: factory.id,
  qty: 100,
  finishedSkuId: finishedVariant.id, // only when producedOnPosted: true
});

Multi-tenancy

Both BillOfMaterials and BomLine use @TenantScoped({ mode: 'optional' }) with a nullable tenantId. Wrap operations in withTenant() from smrt-tenancy to auto-filter reads and writes by tenant.

API surface

ExportDescription
BillOfMaterials / BomLineRecipe + component models. BomLine.effectiveQtyPerUnit() includes waste.
BillOfMaterialsCollectionfindByProduct, findActiveForProduct, findByStatus
BomLineCollectionfindByBom, findByComponent
BomService / createBomService(...)computeMaterialCost, explodeRequirements, canProduce (read-only).
ProductionService / createProductionService(...)consumeMaterials, produceFinishedGoods (writes the ledger).
installManufacturingDispatchHandlers(...)Opt-in production_order:* bus wiring.
BomNotFoundError / NoActiveBomForProductErrorThrown when a BOM id can't be resolved / no active BOM exists for a product.
BomStatus, BomCostRollup, BomLineCost, MaterialRequirement, MaterialShortage, CanProduceResult, ComponentCostResolverExported types.

Related Modules