@classytic/manufacturing
v0.1.0
Published
Manufacturing engine — immutable BOM revisions, work-order lifecycle with crash-healing stock sagas, append-only production ledger, isomorphic pure domain core (explosion, scheduling) that also runs in the browser. MongoDB persistence via @classytic/mongo
Readme
@classytic/manufacturing
Headless BOM + Work-Order kernel for Node.js, backed by Mongoose/mongokit —
no HTTP, no auth. Build a make-to-order or make-to-stock workflow with
multi-level BOM explosion, versioned/immutable BOM revisions, routing
operations, and a Work-Order lifecycle with crash-safe atomic state
transitions. An optional StockPort bridges cleanly to
@classytic/flow (or your own WMS) — the kernel never
assumes one is present.
- Engine-factory kernel.
createManufacturing({ connection, tenant, bridges })returns an engine exposingrepositories.boms/bomRevisions/workOrders/productionEvents. No in-memory adapters ship — this is a Mongo-backed kernel like the rest of the@classytic/*fleet. - Immutable BOM revisions, enforced at the data layer. Once a revision is
active, every write path (not just the service guard) refuses viaimmutableStatesPlugin— directupdate/delete/updateManyon an active revision throwRevisionImmutableError. - Atomic state transitions. Every BOM / Work-Order state change is a CAS
against the expected status, so concurrent workers never double-release or
double-complete.
release()andcomplete()claim the new status before touching stock, so a losing racer is rejected before any reservation or consumption runs. - Multi-level BOM explosion. Sub-assemblies expand into leaf components, aggregated across paths, with cycle detection and a depth guard.
- Routing operations. A BOM revision's
operationssteps land on the work order atplan()time;startOperation/completeOperation/skipOperationenforce sequence order. - Crash healing. Stock-touching verbs stamp the exact request onto
pendingStockin the same CAS write;healPendingStock()re-fires a stranded hand-off idempotently (viabatchRef). - Events out of the box.
manufacturing:bom.*/manufacturing:work_order.*.
Install
npm install @classytic/manufacturingPeers: @classytic/primitives, @classytic/mongokit, @classytic/repo-core, mongoose.
Make-to-order, end-to-end
import mongoose from 'mongoose';
import { createManufacturing } from '@classytic/manufacturing';
const connection = mongoose.createConnection(process.env.MONGO_URI!);
const engine = await createManufacturing({
connection,
tenant: { tenantField: 'organizationId', fieldType: 'string', required: true },
bridges: { stock: myStockPort }, // optional — omit for open-loop (no inventory mutation)
});
const ctx = { organizationId: 'org-1', actorId: 'engineer-1' };
const { boms, bomRevisions, workOrders } = engine.repositories;
// 1. Engineer publishes an active default BOM.
const { bom, revision } = await boms.createBom(
{
productSku: 'CHAIR',
isDefault: true,
content: {
outputQuantity: 1,
components: [
{ itemSku: 'LEG', quantity: 4 },
{ itemSku: 'SEAT', quantity: 1 },
{ itemSku: 'SCREW', quantity: 8, scrapPct: 25 },
],
},
},
ctx,
);
await boms.activateRevision(String(bom._id), String(revision._id), ctx);
// 2. Sales accepts an order — host triggers make-to-order.
const wo = await workOrders.createWorkOrder({ productSku: 'CHAIR', plannedQuantity: 10 }, ctx);
await workOrders.plan(String(wo._id), ctx); // explodes the active revision onto component lines
await workOrders.release(String(wo._id), ctx); // reserves components via bridges.stock
await workOrders.start(String(wo._id), { by: 'operator' }, ctx);
await workOrders.complete(String(wo._id), { producedQuantity: 10 }, ctx); // consumes + producesEngineering changes clone a revision rather than mutate an active one:
const rev2 = await boms.createRevision(String(bom._id), { changeNote: 'thicker screws' }, ctx);
await boms.activateRevision(String(bom._id), String(rev2._id), ctx); // supersedes rev 1BOM explosion
plan() calls this internally; it is also exported as a pure function:
import { explodeBom, mapLookup } from '@classytic/manufacturing';
const requirements = await explodeBom(
mainBom,
qty,
mapLookup([mainBom, subAssyBom]), // multi-level: pass a SubBomLookup
{ includeScrap: true, maxDepth: 16 },
);- Linear
qtyscaling. scrapPctper line —(1 + scrapPct/100)uplift whenincludeScrap: true.- Duplicate SKUs across paths collapse to one
ComponentRequirementwithpathCount > 1. - Cycles throw
CircularBomErrorwith the offending cycle trace. - Depth past
maxDepththrowsBomDepthExceededError. outputQuantityon a BOM divides the parent qty (e.g. one batch produces 5 units).isOptionallines are included by default; the flag survives aggregation only when EVERY contributing path was optional.substitutes(alternative SKUs) are carried through as data, unioned across paths — the kernel never auto-substitutes; hosts swap the SKU before release.
BOM types
Bom.type is normal (default) | phantom | kit:
| Type | Explosion | Work orders |
|---|---|---|
| normal | components as declared | allowed |
| phantom | looked through to its components (never stocked) | NonProducibleBomError |
| kit | components as declared — exploded at fulfillment by the HOST | NonProducibleBomError |
Routing operations
A BOM revision's operations (sequence + operation name + optional
workCenterRef/expectedDurationMinutes) land on the work order as
pending steps at plan() time — no separate routing repository:
await boms.createBom(
{
productSku: 'CHAIR',
isDefault: true,
content: { /* ...components */ },
operations: [
{ sequence: 10, operation: 'cut', workCenterRef: 'wc_cut', expectedDurationMinutes: 30 },
{ sequence: 20, operation: 'weld', workCenterRef: 'wc_weld' },
{ sequence: 30, operation: 'assemble' },
],
},
ctx,
);
// while the work order is in_progress:
await workOrders.startOperation(String(wo._id), 10, { by: 'ali' }, ctx);
await workOrders.completeOperation(String(wo._id), 10, {}, ctx);
await workOrders.skipOperation(String(wo._id), 20, { by: 'supervisor', note: 'pre-welded batch' }, ctx);Operations execute in sequence order — starting one while a lower-sequence
operation is unfinished throws OperationSequenceError. complete() refuses
while any operation is not done/skipped (OperationsIncompleteError)
unless you pass allowIncompleteOperations: true. workCenterRef is an
opaque string: capacity, scheduling and cost-per-hour belong to the host or a
planner above this kernel.
Work-order state machine
draft → planned → released → in_progress → completed
↘ ↘ ↘ ↘
cancelled (from any non-terminal state)Each transition is CAS-guarded and appends to statusHistory. Invalid
transitions throw InvalidTransitionError.
| Verb | What happens | Stock side effect |
|---|---|---|
| createWorkOrder | Persist draft, no stock touched | none |
| plan | Explode the active BOM revision, write component + operation lines | none |
| release | Reserve each component (if bridges.stock present) | reserve(...) per line |
| start | Mark in_progress, record actualStart | none |
| complete | Consume reservations, produce finished goods | consume(...) + produce(...) |
| cancel | Abort at any non-terminal state | release(...) outstanding reservations |
Partial runs
Passing producedQuantity < plannedQuantity to complete() prorates
component consumption. producedQuantity > plannedQuantity (over-completion)
and negative quantities are rejected with OverCompletionError, never
silently clamped.
Crash healing — pendingStock + healPendingStock()
Every stock-touching verb stamps the exact requests it is about to fire into
WorkOrder.pendingStock in the same atomic CAS write that claims the
status, and clears the stamp when the port call succeeds. If the process
dies — or the stock port throws — in between, the stamp survives on the doc:
// Sweep (cron / startup): finish any stranded hand-off.
const stranded = await workOrders.getAll({ /* pendingStock != null */ }, ctx);
for (const wo of stranded.docs) await workOrders.healPendingStock(String(wo._id), ctx);healPendingStock() re-fires the stored requests, clears the stamp, and
emits manufacturing:work_order.stock_healed — not the original
released/completed/cancelled event, so a downstream consumer that keys
off the lifecycle event (invoicing, FG receipt) must also subscribe to the
heal event if it needs to observe a healed transition. Every request in a
batch carries the original attempt's batchRef; a durable StockPort
adapter MUST treat it as an idempotency key so a heal that follows a
partially executed attempt never double-applies.
Stock integration
StockPort is a four/five-method interface the kernel calls at
release / complete / cancel time:
interface StockPort {
reserve(requests): Promise<StockReservationResult[]>;
release(reservationRefs): Promise<void>;
consume(requests): Promise<void>; // handles partial-consume
produce(requests): Promise<void>;
checkAvailability?(skus): Promise<StockAvailability[]>; // optional preflight
}Omit bridges.stock entirely to run "open-loop" — work orders progress
through their lifecycle with no inventory mutation. For production, write a
thin adapter mapping StockPort onto @classytic/flow (or your WMS) —
bridges are optional integrations, not bundled here.
Events
| Event | When | Payload |
|---|---|---|
| manufacturing:bom.created | createBom | { bomId, productSku } |
| manufacturing:bom.activated | activateRevision | { bomId, revisionId } |
| manufacturing:bom.archived | archiveBom | { bomId } |
| manufacturing:bom_revision.created | createRevision | { bomId, revisionId } |
| manufacturing:bom_revision.activated | activateRevision | { bomId, revisionId } |
| manufacturing:bom_revision.archived | archiveRevision | { bomId, revisionId } |
| manufacturing:work_order.created | createWorkOrder | { workOrderId, bomId, productSku, plannedQuantity } |
| manufacturing:work_order.planned | plan | { workOrderId, componentCount, operationCount } |
| manufacturing:work_order.released | release | { workOrderId } |
| manufacturing:work_order.started | start | { workOrderId, actualStart } |
| manufacturing:work_order.completed | complete | { workOrderId, producedQuantity, actualEnd } |
| manufacturing:work_order.cancelled | cancel | { workOrderId, reason } |
| manufacturing:work_order.stock_healed | healPendingStock | { workOrderId, verb } |
| manufacturing:work_order.operation_started/completed/skipped | *Operation | { workOrderId, sequence } |
What this kernel does NOT do (v0.1.0)
- No cost roll-up. No
Money/minor-units type anywhere — material, labor, and overhead cost roll-up, standard-vs-actual costing, and WIP valuation are not modeled. This is the largest gap for a full ERP manufacturing module; a costing layer is expected in a later release. - No by-product / co-product production.
revision.byproductsis stored and cloned across revisions but never yields aproduce()call at completion — declared by-products currently produce no stock. - No subcontracting beyond the
NonProducibleBomErrorseam. - No master production schedule / capacity planning. That belongs in a
planner above this kernel (see
@classytic/planning). - No UoM conversion.
uomis carried through but never converted — hosts pin one measure per SKU. - Production ledger is best-effort, not transactional.
productionEventsappend happens outside the state-write transaction; a transient write failure logs and continues rather than rolling back the work-order state. Don't treat the ledger as a strict source of truth for WIP-as-of-date queries until this is hardened.
Known limitation — test coverage
The current suite is 15 tests (unit + integration), covering the BOM
revision lifecycle, immutability enforcement, and one full make-to-order
work-order path. It does not yet exercise: cancel()'s stock-release
path, archiveBom/archiveRevision, setDefault exclusivity,
approval-gated activation, the consume_produce/release_refs heal kinds
(only the reserve heal is tested), or concurrent-CAS races. Treat this as
an early release — the engine core (saga discipline, immutable revisions,
tenancy) is solid, but breadth of test coverage has not caught up to the
verb surface.
Architecture
src/
├── index.ts ← main barrel
├── domain/
│ ├── entities/ ← Bom, WorkOrder, ComponentRequirement
│ ├── enums/ ← bom-status / bom-type / work-order-status / operation-status
│ ├── ports/ ← StockPort (optional bridge), context
│ ├── pure/ ← explodeBom + mapLookup (isomorphic, no I/O)
│ └── errors.ts
├── engine/
│ ├── create-manufacturing.ts ← engine factory
│ └── engine-types.ts
├── models/ ← Mongoose schemas (Bom, BomRevision, WorkOrder, ProductionEvent)
├── repositories/ ← BomRepository, BomRevisionRepository, WorkOrderRepository, ProductionEventRepository
├── schemas/ ← Zod input validation
└── events/
└── event-constants.ts ← MANUFACTURING_EVENTS mapTests
npm test # unit + integration
npm run test:unit # unit only
npm run test:integrationLicense
See LICENSE.
