@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.
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
pnpm add @happyvertical/smrt-manufacturingDepends 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.
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:
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:
| Field | Type | Description |
|---|---|---|
bomId | string | The BOM that was rolled up. |
totalCost | number | Total material cost per produced unit. |
currency | string | ISO 4217 code; defaults to 'USD'. |
lineBreakdown | BomLineCost[] | Per line: qtyPerUnit, wastePercent, effectiveQty, unitCost, lineCost, uom, costUnavailable. |
hasMissingCosts | boolean | true 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:
// 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:
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.
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
| Export | Description |
|---|---|
BillOfMaterials / BomLine | Recipe + component models. BomLine.effectiveQtyPerUnit() includes waste. |
BillOfMaterialsCollection | findByProduct, findActiveForProduct, findByStatus |
BomLineCollection | findByBom, findByComponent |
BomService / createBomService(...) | computeMaterialCost, explodeRequirements, canProduce (read-only). |
ProductionService / createProductionService(...) | consumeMaterials, produceFinishedGoods (writes the ledger). |
installManufacturingDispatchHandlers(...) | Opt-in production_order:* bus wiring. |
BomNotFoundError / NoActiveBomForProductError | Thrown when a BOM id can't be resolved / no active BOM exists for a product. |
BomStatus, BomCostRollup, BomLineCost, MaterialRequirement, MaterialShortage, CanProduceResult, ComponentCostResolver | Exported types. |