@happyvertical/smrt-manufacturing 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,157 @@
1
+ # @happyvertical/smrt-manufacturing
2
+
3
+ Bills of materials, cost rollup, and production-order operations. Strictly industry-neutral — the same primitives serve apparel, furniture, automotive, CPG, electronics, food production, custom hardware, and any vertical that builds finished goods from a recipe.
4
+
5
+ Sits on top of `@happyvertical/smrt-inventory` (stock) and works alongside the `ProductionOrder` Contract STI subtype already shipped in `@happyvertical/smrt-commerce`.
6
+
7
+ ## Models
8
+
9
+ | Model | Purpose |
10
+ |---|---|
11
+ | `BillOfMaterials` | Recipe for a finished product. `productId` (plain string) references the upstream `Product` or any STI subtype. Multiple revisions per product via `version` + `status` (`draft` / `active` / `superseded`). `conflictColumns: ['product_id', 'version', 'tenant_id']`. |
12
+ | `BomLine` | One component on a BOM. `bomId` (FK), `componentSkuId` (plain string ref — the `Sku` model lives in `@happyvertical/smrt-products`; inventory tracks stock motion against the id), `qtyPerUnit`, `uom` (open-ended — `yards`, `each`, `grams`, `kg`, ...), `wastePercent`, `notes`. `conflictColumns: ['bom_id', 'component_sku_id', 'tenant_id']`. |
13
+
14
+ Both models are `@TenantScoped({ mode: 'optional' })` with a nullable `tenantId` so they can be used either tenant-scoped or globally.
15
+
16
+ `RoutingStep` (labor cost) is intentionally out of scope for v1 — see issue [#1245](https://github.com/happyvertical/smrt/issues/1245) for the planned follow-up.
17
+
18
+ ## BomService — planning helpers
19
+
20
+ ```typescript
21
+ import { BomService } from '@happyvertical/smrt-manufacturing';
22
+
23
+ const bom = await BomService.create({
24
+ db,
25
+ // Optional. Resolve unit cost for a component SKU.
26
+ // Without this, every line rolls up to $0 and `costUnavailable` is set.
27
+ costResolver: async (componentSkuId) => fetchLatestCost(componentSkuId),
28
+ });
29
+
30
+ const rollup = await bom.computeMaterialCost(bomId);
31
+ // { totalCost, currency, lineBreakdown, hasMissingCosts }
32
+
33
+ const requirements = await bom.explodeRequirements(bomId, 100);
34
+ // [{ componentSkuId, totalQty, uom }, ...]
35
+
36
+ const check = await bom.canProduce(bomId, 100);
37
+ // { ok: true } | { ok: false, shortages: [...] }
38
+ ```
39
+
40
+ | Method | Behavior |
41
+ |---|---|
42
+ | `computeMaterialCost(bomId)` | Walks every BomLine, applies waste (`qtyPerUnit * (1 + wastePercent / 100)`), resolves unit costs via the optional `costResolver`, returns per-line breakdown plus rolled-up total. Lines with no cost set `costUnavailable: true` and contribute `0`. |
43
+ | `explodeRequirements(bomId, qty)` | Returns a "shopping list" of materials needed for `qty` units. Duplicates across lines are summed. Does NOT mutate stock. |
44
+ | `canProduce(bomId, qty)` | Calls `explodeRequirements`, then sums `available` stock across every location per component, returns `{ ok: true }` if everything's covered, else `{ ok: false, shortages: [...] }`. |
45
+
46
+ ## ProductionService — consume / produce
47
+
48
+ ```typescript
49
+ import { ProductionService } from '@happyvertical/smrt-manufacturing';
50
+
51
+ const production = await ProductionService.create({ db });
52
+
53
+ // Drain materials at the factory.
54
+ const consumed = await production.consumeMaterials(
55
+ { id: order.id, productId: order.productId, bomId: order.bomId },
56
+ { locationId: factory.id, qty: runQty },
57
+ );
58
+
59
+ // Receive finished goods.
60
+ const produced = await production.produceFinishedGoods(
61
+ { id: order.id, productId: order.productId },
62
+ { locationId: factory.id, qty: runQty, finishedSkuId: variant.id },
63
+ );
64
+
65
+ // Or run both in one transaction — see "Joint atomicity" below.
66
+ const { consumed, produced } = await production.runProduction(
67
+ { id: order.id, productId: order.productId, bomId: order.bomId },
68
+ {
69
+ consume: { locationId: factory.id, qty: runQty },
70
+ produce: { locationId: factory.id, qty: runQty, finishedSkuId: variant.id },
71
+ },
72
+ );
73
+ ```
74
+
75
+ All three methods write through `StockService` and stamp every emitted `StockMovement` with `sourceType: 'ProductionOrder'` + the production order id so audit queries can roll them up later.
76
+
77
+ ### Joint atomicity — `runProduction` vs the two-call form
78
+
79
+ `consumeMaterials` and `produceFinishedGoods` are each individually atomic, but calling them as two separate awaits is NOT jointly atomic — each opens its own `stockService.withTransaction(...)` scope. If something goes wrong between the two calls (process crash, transient adapter failure on the produce leg), you can land in a state where materials are deducted but no finished SKU receipt balances them. The audit ledger stays consistent within each call; what's missing is the cross-call invariant.
80
+
81
+ When you need that invariant — typically make-to-stock flows where the factory step is invisible to the ledger — use `runProduction(order, { consume, produce })`. Both legs run inside one transaction; any failure (BOM shortage, adapter error, interceptor reject) rolls back both legs together.
82
+
83
+ When NOT to use it: workflows where consume and produce represent a real wall-clock gap that downstream observers need to see (WIP dashboards, partial-run reporting, separate "materials posted" and "production completed" events on the dispatch bus). There, the two-call form is the right shape — each call is its own ledger event.
84
+
85
+ ### Location convention — explicit-arg design
86
+
87
+ The location where materials are consumed and finished goods are received is passed explicitly to `consumeMaterials` / `produceFinishedGoods`, not carried on the production order itself. Rationale:
88
+
89
+ - The commerce `ProductionOrder` is a `Contract` STI subtype owned by `@happyvertical/smrt-commerce`. Adding an `originLocationId` field there would either need a schema change in commerce (cross-cutting) or a meta field that only manufacturing knows about (leaky).
90
+ - Real shops often pick a location at run time (factory A is congested, route the run through factory B), so even if the order carried a default, the explicit-arg signature is the more flexible canonical form.
91
+ - Callers that want a default can stash a `locationId` on their own production-order helper and pass it through.
92
+
93
+ ### Finished-SKU convention
94
+
95
+ A `ProductionOrder` references a `productId`, but a `Product` typically has multiple SKUs (one per variant). The caller of `produceFinishedGoods` picks the concrete `finishedSkuId` because the multi-SKU mapping is application-specific (size run, finish mix, kit variant).
96
+
97
+ ## Opt-in DispatchBus hooks
98
+
99
+ Off by default. Wire them up explicitly in the application's `smrt.ts`:
100
+
101
+ ```typescript
102
+ import { installManufacturingDispatchHandlers } from '@happyvertical/smrt-manufacturing';
103
+
104
+ const handlers = await installManufacturingDispatchHandlers({
105
+ dispatchBus: bus,
106
+ db,
107
+ // Default: subscribe to production_order:posted, call consumeMaterials.
108
+ installProductionPosted: true,
109
+ // Default: don't auto-produce. Set to true if your shop emits a
110
+ // separate production_order:completed event.
111
+ installProductionCompleted: false,
112
+ // Default: don't combine consume + produce on `posted`. Set to true
113
+ // for make-to-stock-instantly workflows where the factory step is
114
+ // invisible. Ignored when installProductionCompleted is true.
115
+ producedOnPosted: false,
116
+ });
117
+ ```
118
+
119
+ This subscribes to:
120
+
121
+ - `production_order:posted` → `production.consumeMaterials(...)`; when `producedOnPosted: true` the handler instead calls `production.runProduction(...)` so consume + produce share one transaction. Process crashes or adapter errors between the two legs can never leave materials deducted with no finished-goods receipt.
122
+ - `production_order:completed` → `production.produceFinishedGoods(...)` (opt-in)
123
+
124
+ The companion handlers for `contract:created` (reserve) and `fulfillment:shipped` (fulfil) live in `@happyvertical/smrt-inventory` — wire both packages' installers from `smrt.ts` to get the full lifecycle.
125
+
126
+ ## Gotchas
127
+
128
+ - **`computeMaterialCost` without a resolver returns `$0`.** That is deliberate — manufacturing does not assume any particular cost source. A real wiring will plug in `@happyvertical/smrt-products` `Material.costPerUnit`, or a rolling average from purchase-order history, or a vendor price book. The `costUnavailable` flag tells UIs to surface "unknown cost" rather than silently rolling up zeros.
129
+ - **`explodeRequirements` does not call any stock APIs.** It is a planning helper. To check whether the materials are actually on hand, use `canProduce`. To actually deduct them, use `ProductionService.consumeMaterials`.
130
+ - **`canProduce` sums available stock across every location.** The planning question is "do we have it at all?". The operational question of "which warehouse do we pull from?" is left to the caller of `consumeMaterials`, which targets a single `locationId` per call.
131
+ - **`consumeMaterials` propagates `InsufficientStockError`.** If a line would drive `available` below zero, the underlying `StockService.adjust` throws. Pre-flight with `canProduce` before posting if you want to avoid partial-failure mid-run.
132
+ - **`consumeMaterials` is atomic across BOM lines.** All per-line deductions and their audit rows run inside a single `stockService.withTransaction(...)` scope (powered by `@happyvertical/sql >= 0.74.0`'s native `db.transaction()`). An `InsufficientStockError` on line N+1 rolls back lines 1..N so production-order posting never leaves materials half-consumed. The recommended pre-flight (`BomService.canProduce(orderId, qty)`) is still useful when you'd rather know upfront than discover the shortfall mid-run, but a missed pre-flight no longer corrupts state.
133
+ - **`consumeMaterials` + `produceFinishedGoods` are NOT jointly atomic.** Each opens its own transaction. A failure on the produce leg leaves materials deducted with no finished SKU receipt to balance it. Use `runProduction(order, { consume, produce })` when you need both legs to commit or roll back together.
134
+ - **Cross-package references are plain strings.** `productId`, `componentSkuId`, `bomId` (within this package) — all plain string ids, never `@foreignKey()`. Keeps the dependency graph DAG-shaped and lets each upstream package evolve independently.
135
+ - **`conflictColumns` include `tenant_id`** on both models. NULL-matching semantics are handled by `@happyvertical/sql >= 0.74.0`; two saves with the same `(product_id, version, NULL)` tuple merge in place.
136
+ - **Lazy table creation.** Like everything else in SMRT, the `manufacturing_boms` and `manufacturing_bom_lines` tables are created on first DB op via `syncSchema`. Safe for SSR.
137
+ - **Cross-industry constraint.** This package's vocabulary stays generic. Apparel-specific concepts (`Style`, `Makeup`, `Colorway`, `tech-pack`, fashion `Season`) and their analogues in furniture / automotive / CPG live in the relevant template package, never here. PRs that introduce industry vocabulary should be rejected.
138
+
139
+ ## Source attribution
140
+
141
+ Every emitted `StockMovement` carries `sourceType: 'ProductionOrder'` plus `sourceId: order.id` so downstream queries can reconstruct "what caused this movement". Reason codes used:
142
+
143
+ | reasonCode | Emitter | Note |
144
+ |---|---|---|
145
+ | `production_consume` | `ProductionService.consumeMaterials` | One per BOM line per consume call |
146
+ | `production_produce` | `ProductionService.produceFinishedGoods` | One per produce call |
147
+
148
+ These join cleanly with the standard inventory reason codes (`receipt`, `reservation`, `release`, `fulfillment`, `transfer_out`, `transfer_in`, `adjustment`) defined in `@happyvertical/smrt-inventory`.
149
+
150
+ ## Dependencies
151
+
152
+ | Package | Purpose |
153
+ |---|---|
154
+ | `@happyvertical/smrt-core` | SmrtObject / SmrtCollection / DispatchBus |
155
+ | `@happyvertical/smrt-inventory` | StockService, stock levels, movements (the `Sku` model itself lives in `@happyvertical/smrt-products`; inventory tracks stock motion against the id) |
156
+ | `@happyvertical/smrt-tenancy` | Optional tenant scoping |
157
+ | `@happyvertical/sql` | Database adapter |
package/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ @AGENTS.md
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright <2025> <Happy Vertical Corporation>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,224 @@
1
+ # @happyvertical/smrt-manufacturing
2
+
3
+ Bills of materials, cost rollup, and production-order operations for the SMRT framework. Strictly industry-neutral — the same primitives serve apparel, furniture, automotive, CPG, electronics, food production, custom hardware, and any other vertical that builds finished goods from a recipe.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @happyvertical/smrt-manufacturing
9
+ ```
10
+
11
+ This package depends on `@happyvertical/smrt-inventory` (peer-installed via your workspace) for stock operations.
12
+
13
+ ## Usage
14
+
15
+ ### Define a BOM with components
16
+
17
+ ```typescript
18
+ import {
19
+ BillOfMaterialsCollection,
20
+ BomLineCollection,
21
+ } from '@happyvertical/smrt-manufacturing';
22
+
23
+ const db = { type: 'sqlite', url: 'app.db' };
24
+ const boms = await BillOfMaterialsCollection.create({ db });
25
+ const lines = await BomLineCollection.create({ db });
26
+
27
+ const bom = await boms.create({
28
+ productId: shirt.id, // upstream Product or any STI subtype
29
+ version: 1,
30
+ status: 'active',
31
+ currency: 'USD',
32
+ notes: 'Initial revision',
33
+ });
34
+ await bom.save();
35
+
36
+ const fabric = await lines.create({
37
+ bomId: bom.id!,
38
+ componentSkuId: fabricSku.id!,
39
+ qtyPerUnit: 2.0,
40
+ uom: 'yards',
41
+ wastePercent: 10, // 10% cutting waste
42
+ });
43
+ await fabric.save();
44
+
45
+ const buttons = await lines.create({
46
+ bomId: bom.id!,
47
+ componentSkuId: buttonSku.id!,
48
+ qtyPerUnit: 4,
49
+ uom: 'each',
50
+ });
51
+ await buttons.save();
52
+ ```
53
+
54
+ ### Roll up material cost with waste
55
+
56
+ ```typescript
57
+ import { BomService } from '@happyvertical/smrt-manufacturing';
58
+
59
+ const service = await BomService.create({
60
+ db,
61
+ // Plug in any cost source: smrt-products Material.costPerUnit,
62
+ // a purchase-order rolling average, a vendor price book, anything.
63
+ costResolver: async (componentSkuId) => {
64
+ const sku = await skus.get(componentSkuId);
65
+ return sku?.attributes ? Number(JSON.parse(sku.attributes).cost ?? 0) : null;
66
+ },
67
+ });
68
+
69
+ const rollup = await service.computeMaterialCost(bom.id!);
70
+ console.log(rollup.totalCost, rollup.currency);
71
+ // Walks lines, applies waste, surfaces a per-line breakdown.
72
+ ```
73
+
74
+ ### Plan a production run
75
+
76
+ ```typescript
77
+ const requirements = await service.explodeRequirements(bom.id!, 100);
78
+ // [{ componentSkuId: fabricSku.id, totalQty: 220, uom: 'yards' },
79
+ // { componentSkuId: buttonSku.id, totalQty: 400, uom: 'each' }]
80
+
81
+ const check = await service.canProduce(bom.id!, 100);
82
+ if (!check.ok) {
83
+ for (const shortage of check.shortages) {
84
+ console.log(
85
+ `Need ${shortage.requested} of ${shortage.componentSkuId}, have ${shortage.available}`,
86
+ );
87
+ }
88
+ }
89
+ ```
90
+
91
+ ### Execute consume / produce against a production order
92
+
93
+ The `ProductionOrder` row itself lives in `@happyvertical/smrt-commerce` as a `Contract` STI subtype. This package mutates the inventory ledger on its behalf.
94
+
95
+ ```typescript
96
+ import { ProductionService } from '@happyvertical/smrt-manufacturing';
97
+
98
+ const production = await ProductionService.create({ db });
99
+
100
+ // Pull materials from the factory.
101
+ const consumed = await production.consumeMaterials(
102
+ {
103
+ id: order.id, // ProductionOrder.id
104
+ productId: order.productId,
105
+ },
106
+ {
107
+ locationId: factory.id, // explicit — not stored on the order
108
+ qty: 100,
109
+ },
110
+ );
111
+
112
+ // Receive finished goods.
113
+ const produced = await production.produceFinishedGoods(
114
+ { id: order.id, productId: order.productId },
115
+ {
116
+ locationId: factory.id,
117
+ qty: 100,
118
+ finishedSkuId: finishedVariant.id, // explicit — one productId can have many SKUs
119
+ },
120
+ );
121
+ ```
122
+
123
+ Every emitted `StockMovement` is stamped with `sourceType: 'ProductionOrder'` plus `sourceId: order.id` so audit queries can reconstruct what happened later via `StockMovementCollection.findBySource('ProductionOrder', order.id)`.
124
+
125
+ ### Multi-tenancy
126
+
127
+ Both `BillOfMaterials` and `BomLine` use `@TenantScoped({ mode: 'optional' })` with a nullable `tenantId`. Wrap mutations in `withTenant()` from `@happyvertical/smrt-tenancy` to scope queries automatically.
128
+
129
+ ```typescript
130
+ import { withTenant } from '@happyvertical/smrt-tenancy';
131
+
132
+ await withTenant({ tenantId: 'tenant-a' }, async () => {
133
+ const requirements = await service.explodeRequirements(bom.id!, 50);
134
+ // Reads are auto-filtered by tenant_id = 'tenant-a'.
135
+ });
136
+ ```
137
+
138
+ ### Opt-in DispatchBus wiring
139
+
140
+ The package ships handlers that bridge production-order lifecycle events to the consume / produce flow. Off by default; install them explicitly in your `smrt.ts`:
141
+
142
+ ```typescript
143
+ import { createDispatchBus } from '@happyvertical/smrt-core';
144
+ import { installInventoryDispatchHandlers } from '@happyvertical/smrt-inventory';
145
+ import { installManufacturingDispatchHandlers } from '@happyvertical/smrt-manufacturing';
146
+
147
+ const bus = await createDispatchBus({ db });
148
+
149
+ // Inventory handlers bridge contract:created and fulfillment:shipped.
150
+ await installInventoryDispatchHandlers({ dispatchBus: bus, db });
151
+
152
+ // Manufacturing handlers bridge production_order:posted (and optionally
153
+ // production_order:completed) to consume / produce.
154
+ await installManufacturingDispatchHandlers({
155
+ dispatchBus: bus,
156
+ db,
157
+ // Consume and produce in one shot when posted (make-to-stock).
158
+ producedOnPosted: true,
159
+ });
160
+
161
+ // Later, when a production order is posted:
162
+ await bus.emit('production_order:posted', {
163
+ productionOrderId: order.id,
164
+ productId: order.productId,
165
+ locationId: factory.id,
166
+ qty: 100,
167
+ finishedSkuId: finishedVariant.id, // only needed when producedOnPosted: true
168
+ });
169
+ ```
170
+
171
+ Per-handler toggles (`installProductionPosted`, `installProductionCompleted`) let consumers pick exactly the legs they want. The companion `contract:created` and `fulfillment:shipped` handlers live in `@happyvertical/smrt-inventory`.
172
+
173
+ ## API
174
+
175
+ ### Models
176
+
177
+ | Export | Description |
178
+ |---|---|
179
+ | `BillOfMaterials` | Recipe for one finished product. Versioned with a `draft` / `active` / `superseded` lifecycle. |
180
+ | `BomLine` | One component on a BOM. `effectiveQtyPerUnit()` returns the qty including waste. |
181
+
182
+ ### Collections
183
+
184
+ | Export | Description |
185
+ |---|---|
186
+ | `BillOfMaterialsCollection` | `findByProduct`, `findActiveForProduct`, `findByStatus` |
187
+ | `BomLineCollection` | `findByBom`, `findByComponent` |
188
+
189
+ ### Services
190
+
191
+ | Export | Description |
192
+ |---|---|
193
+ | `BomService` | Cost rollup, requirements explosion, can-produce check. |
194
+ | `createBomService({ db, costResolver? })` | Convenience factory. |
195
+ | `ProductionService` | Operational consume / produce against a production order. |
196
+ | `createProductionService({ db })` | Convenience factory. |
197
+ | `installManufacturingDispatchHandlers({ dispatchBus, db })` | Opt-in bus wiring. |
198
+ | `BomNotFoundError` | Thrown when a BOM id cannot be resolved. |
199
+ | `NoActiveBomForProductError` | Thrown by `ProductionService` when neither an explicit `bomId` nor an active BOM is available for a production order. |
200
+
201
+ ### Types
202
+
203
+ | Export | Description |
204
+ |---|---|
205
+ | `BomStatus` | `'draft' \| 'active' \| 'superseded'` |
206
+ | `BomCostRollup` | Return shape of `computeMaterialCost`. |
207
+ | `BomLineCost` | Per-line entry inside a `BomCostRollup`. |
208
+ | `MaterialRequirement` | Entry returned by `explodeRequirements`. |
209
+ | `MaterialShortage` | Entry returned by `canProduce` when stock is insufficient. |
210
+ | `CanProduceResult` | `{ ok: true; shortages: [] } \| { ok: false; shortages: [...] }` |
211
+ | `ComponentCostResolver` | Async (or sync) callback returning unit cost or `null`. |
212
+
213
+ ## Dependencies
214
+
215
+ | Package | Purpose |
216
+ |---|---|
217
+ | `@happyvertical/smrt-core` | SmrtObject / SmrtCollection / DispatchBus |
218
+ | `@happyvertical/smrt-inventory` | StockService (consume / produce target) |
219
+ | `@happyvertical/smrt-tenancy` | Optional tenant scoping |
220
+ | `@happyvertical/sql` | Database adapter |
221
+
222
+ ## License
223
+
224
+ MIT