@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 +157 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +224 -0
- package/dist/index.d.ts +846 -0
- package/dist/index.js +810 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.json +623 -0
- package/dist/smrt-knowledge.json +517 -0
- package/dist/types.d.ts +138 -0
- package/dist/types.js +23 -0
- package/dist/types.js.map +1 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
import { ObjectRegistry, field, smrt, SmrtObject, SmrtCollection } from "@happyvertical/smrt-core";
|
|
2
|
+
import { tenantId, TenantScoped } from "@happyvertical/smrt-tenancy";
|
|
3
|
+
import { createStockService } from "@happyvertical/smrt-inventory";
|
|
4
|
+
import { BomNotFoundError, NoActiveBomForProductError } from "./types.js";
|
|
5
|
+
import { createLogger } from "@happyvertical/logger";
|
|
6
|
+
ObjectRegistry.registerPackageManifest(
|
|
7
|
+
new URL("./manifest.json", import.meta.url)
|
|
8
|
+
);
|
|
9
|
+
var __defProp$1 = Object.defineProperty;
|
|
10
|
+
var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
|
|
11
|
+
var __decorateClass$1 = (decorators, target, key, kind) => {
|
|
12
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
|
|
13
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
14
|
+
if (decorator = decorators[i])
|
|
15
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
16
|
+
if (kind && result) __defProp$1(target, key, result);
|
|
17
|
+
return result;
|
|
18
|
+
};
|
|
19
|
+
let BillOfMaterials = class extends SmrtObject {
|
|
20
|
+
tenantId = null;
|
|
21
|
+
productId = "";
|
|
22
|
+
version = 1;
|
|
23
|
+
/**
|
|
24
|
+
* Date from which this BOM is intended to take effect for new production
|
|
25
|
+
* runs. Optional — older BOMs imported from legacy systems may have no
|
|
26
|
+
* effective date on file.
|
|
27
|
+
*/
|
|
28
|
+
effectiveDate = null;
|
|
29
|
+
status = "draft";
|
|
30
|
+
/**
|
|
31
|
+
* Optional free-form notes shown in admin UIs (revision reason, source
|
|
32
|
+
* document reference, sign-off chain, etc.).
|
|
33
|
+
*/
|
|
34
|
+
notes = "";
|
|
35
|
+
/**
|
|
36
|
+
* ISO 4217 currency code for cost rollups. Mostly a hint for the rollup
|
|
37
|
+
* service — defaults to `'USD'` so older imports without a currency
|
|
38
|
+
* still produce sensible numbers.
|
|
39
|
+
*/
|
|
40
|
+
currency = "USD";
|
|
41
|
+
constructor(options = {}) {
|
|
42
|
+
super(options);
|
|
43
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
44
|
+
if (options.productId !== void 0) this.productId = options.productId;
|
|
45
|
+
if (options.version !== void 0) this.version = options.version;
|
|
46
|
+
if (options.effectiveDate !== void 0) {
|
|
47
|
+
this.effectiveDate = options.effectiveDate === null ? null : options.effectiveDate instanceof Date ? options.effectiveDate : new Date(options.effectiveDate);
|
|
48
|
+
}
|
|
49
|
+
if (options.status !== void 0) this.status = options.status;
|
|
50
|
+
if (options.notes !== void 0) this.notes = options.notes;
|
|
51
|
+
if (options.currency !== void 0) this.currency = options.currency;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
__decorateClass$1([
|
|
55
|
+
tenantId({ nullable: true })
|
|
56
|
+
], BillOfMaterials.prototype, "tenantId", 2);
|
|
57
|
+
__decorateClass$1([
|
|
58
|
+
field({ required: true })
|
|
59
|
+
], BillOfMaterials.prototype, "productId", 2);
|
|
60
|
+
__decorateClass$1([
|
|
61
|
+
field({ required: true })
|
|
62
|
+
], BillOfMaterials.prototype, "version", 2);
|
|
63
|
+
__decorateClass$1([
|
|
64
|
+
field({ required: true })
|
|
65
|
+
], BillOfMaterials.prototype, "status", 2);
|
|
66
|
+
BillOfMaterials = __decorateClass$1([
|
|
67
|
+
TenantScoped({ mode: "optional" }),
|
|
68
|
+
smrt({
|
|
69
|
+
tableName: "manufacturing_boms",
|
|
70
|
+
// Natural key: one BOM per (product, version) per tenant. Re-saving the
|
|
71
|
+
// same combination is an upsert rather than a UNIQUE violation.
|
|
72
|
+
conflictColumns: ["product_id", "version", "tenant_id"],
|
|
73
|
+
api: { include: ["list", "get", "create", "update"] },
|
|
74
|
+
mcp: { include: ["list", "get"] },
|
|
75
|
+
cli: true
|
|
76
|
+
})
|
|
77
|
+
], BillOfMaterials);
|
|
78
|
+
class BillOfMaterialsCollection extends SmrtCollection {
|
|
79
|
+
static _itemClass = BillOfMaterials;
|
|
80
|
+
/**
|
|
81
|
+
* Return every BOM for the given upstream product id, newest version
|
|
82
|
+
* first. Useful for "show me all revisions of this product's recipe"
|
|
83
|
+
* admin screens.
|
|
84
|
+
*/
|
|
85
|
+
async findByProduct(productId) {
|
|
86
|
+
return this.list({ where: { productId }, orderBy: "version DESC" });
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Return the currently `active` BOM for the given product, or `null` if
|
|
90
|
+
* none has been activated yet. When multiple `active` rows exist (which
|
|
91
|
+
* should not happen but can arise during partial migrations), the
|
|
92
|
+
* highest-version row wins.
|
|
93
|
+
*/
|
|
94
|
+
async findActiveForProduct(productId) {
|
|
95
|
+
const matches = await this.list({
|
|
96
|
+
where: { productId, status: "active" },
|
|
97
|
+
orderBy: "version DESC",
|
|
98
|
+
limit: 1
|
|
99
|
+
});
|
|
100
|
+
return matches[0] ?? null;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Return every BOM with the given lifecycle status across all products.
|
|
104
|
+
* Mostly useful for admin dashboards (e.g. "show every draft BOM").
|
|
105
|
+
*/
|
|
106
|
+
async findByStatus(status) {
|
|
107
|
+
return this.list({ where: { status }, orderBy: "productId ASC" });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
var __defProp = Object.defineProperty;
|
|
111
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
112
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
113
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
114
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
115
|
+
if (decorator = decorators[i])
|
|
116
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
117
|
+
if (kind && result) __defProp(target, key, result);
|
|
118
|
+
return result;
|
|
119
|
+
};
|
|
120
|
+
let BomLine = class extends SmrtObject {
|
|
121
|
+
tenantId = null;
|
|
122
|
+
bomId = "";
|
|
123
|
+
componentSkuId = "";
|
|
124
|
+
qtyPerUnit = 0;
|
|
125
|
+
/**
|
|
126
|
+
* Unit of measure for {@link qtyPerUnit}. Open-ended string so the
|
|
127
|
+
* framework does not constrain the vocabulary — `'yards'`, `'each'`,
|
|
128
|
+
* `'grams'`, `'kg'`, `'m'`, `'litres'`, `'sq_ft'`, anything.
|
|
129
|
+
*
|
|
130
|
+
* Conventions follow the upstream `Material.uom` value when applicable.
|
|
131
|
+
*/
|
|
132
|
+
uom = "each";
|
|
133
|
+
wastePercent = 0;
|
|
134
|
+
/**
|
|
135
|
+
* Optional free-form notes — e.g. "primary structural component",
|
|
136
|
+
* "60-second cure", or "matched batch only".
|
|
137
|
+
*/
|
|
138
|
+
notes = "";
|
|
139
|
+
constructor(options = {}) {
|
|
140
|
+
super(options);
|
|
141
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
142
|
+
if (options.bomId !== void 0) this.bomId = options.bomId;
|
|
143
|
+
if (options.componentSkuId !== void 0)
|
|
144
|
+
this.componentSkuId = options.componentSkuId;
|
|
145
|
+
if (options.qtyPerUnit !== void 0) this.qtyPerUnit = options.qtyPerUnit;
|
|
146
|
+
if (options.uom !== void 0) this.uom = options.uom;
|
|
147
|
+
if (options.wastePercent !== void 0)
|
|
148
|
+
this.wastePercent = options.wastePercent;
|
|
149
|
+
if (options.notes !== void 0) this.notes = options.notes;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Effective quantity consumed per produced unit, including waste. Used
|
|
153
|
+
* by {@link BomService} for cost rollups and requirements explosions.
|
|
154
|
+
*
|
|
155
|
+
* `effectiveQty = qtyPerUnit * (1 + wastePercent / 100)`
|
|
156
|
+
*/
|
|
157
|
+
effectiveQtyPerUnit() {
|
|
158
|
+
const qty = Number(this.qtyPerUnit ?? 0);
|
|
159
|
+
const waste = Number(this.wastePercent ?? 0);
|
|
160
|
+
return qty * (1 + waste / 100);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
__decorateClass([
|
|
164
|
+
tenantId({ nullable: true })
|
|
165
|
+
], BomLine.prototype, "tenantId", 2);
|
|
166
|
+
__decorateClass([
|
|
167
|
+
field({ required: true })
|
|
168
|
+
], BomLine.prototype, "bomId", 2);
|
|
169
|
+
__decorateClass([
|
|
170
|
+
field({ required: true })
|
|
171
|
+
], BomLine.prototype, "componentSkuId", 2);
|
|
172
|
+
__decorateClass([
|
|
173
|
+
field({ type: "decimal" })
|
|
174
|
+
], BomLine.prototype, "qtyPerUnit", 2);
|
|
175
|
+
__decorateClass([
|
|
176
|
+
field({ type: "decimal" })
|
|
177
|
+
], BomLine.prototype, "wastePercent", 2);
|
|
178
|
+
BomLine = __decorateClass([
|
|
179
|
+
TenantScoped({ mode: "optional" }),
|
|
180
|
+
smrt({
|
|
181
|
+
tableName: "manufacturing_bom_lines",
|
|
182
|
+
// Natural key: one row per `(bom, component)` per tenant. Re-saving the
|
|
183
|
+
// same combination updates the row in place rather than violating the
|
|
184
|
+
// UNIQUE constraint. Callers that want to declare two distinct lines for
|
|
185
|
+
// the same component (e.g. "main" vs "trim" passes) should use distinct
|
|
186
|
+
// component SKUs or model the variants in inventory.
|
|
187
|
+
conflictColumns: ["bom_id", "component_sku_id", "tenant_id"],
|
|
188
|
+
api: { include: ["list", "get", "create", "update"] },
|
|
189
|
+
mcp: { include: ["list", "get"] },
|
|
190
|
+
cli: true
|
|
191
|
+
})
|
|
192
|
+
], BomLine);
|
|
193
|
+
class BomLineCollection extends SmrtCollection {
|
|
194
|
+
static _itemClass = BomLine;
|
|
195
|
+
/**
|
|
196
|
+
* Return every line that belongs to the given BOM. Caller-stable order
|
|
197
|
+
* helps make cost rollups and requirements explosions deterministic.
|
|
198
|
+
*/
|
|
199
|
+
async findByBom(bomId) {
|
|
200
|
+
return this.list({ where: { bomId }, orderBy: "componentSkuId ASC" });
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Return every line that references the given component SKU across all
|
|
204
|
+
* BOMs. Useful for "where is this material used?" admin screens and for
|
|
205
|
+
* cost-change impact analysis (a price hike on a raw material affects
|
|
206
|
+
* every BOM in the result).
|
|
207
|
+
*/
|
|
208
|
+
async findByComponent(componentSkuId) {
|
|
209
|
+
return this.list({ where: { componentSkuId }, orderBy: "bomId ASC" });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
class BomService {
|
|
213
|
+
constructor(boms, lines, stockService, costResolver) {
|
|
214
|
+
this.boms = boms;
|
|
215
|
+
this.lines = lines;
|
|
216
|
+
this.stockService = stockService;
|
|
217
|
+
this.costResolver = costResolver;
|
|
218
|
+
}
|
|
219
|
+
boms;
|
|
220
|
+
lines;
|
|
221
|
+
stockService;
|
|
222
|
+
costResolver;
|
|
223
|
+
/** Factory — prefer {@link createBomService}. */
|
|
224
|
+
static async create(options) {
|
|
225
|
+
const stockService = options.stockService ?? await buildStockService$1(options);
|
|
226
|
+
const sharedDb = options.db ?? stockService.db;
|
|
227
|
+
const [boms, lines] = await Promise.all([
|
|
228
|
+
BillOfMaterialsCollection.create({ db: sharedDb }),
|
|
229
|
+
BomLineCollection.create({ db: sharedDb })
|
|
230
|
+
]);
|
|
231
|
+
return new BomService(boms, lines, stockService, options.costResolver);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Walk every {@link BomLine} for the given BOM, resolve each line's
|
|
235
|
+
* component cost, apply waste, and return the rolled-up material cost
|
|
236
|
+
* per produced unit along with a per-line breakdown.
|
|
237
|
+
*
|
|
238
|
+
* Throws {@link BomNotFoundError} when the BOM does not exist.
|
|
239
|
+
*
|
|
240
|
+
* Lines whose cost cannot be resolved contribute `0` to the total and
|
|
241
|
+
* set `costUnavailable: true` on their breakdown row; the aggregate
|
|
242
|
+
* `hasMissingCosts` flag mirrors this so callers can surface a UI
|
|
243
|
+
* warning.
|
|
244
|
+
*/
|
|
245
|
+
async computeMaterialCost(bomId) {
|
|
246
|
+
const bom = await this.requireBom(bomId);
|
|
247
|
+
const lines = await this.lines.findByBom(bomId);
|
|
248
|
+
const lineBreakdown = [];
|
|
249
|
+
let totalCost = 0;
|
|
250
|
+
let hasMissingCosts = false;
|
|
251
|
+
for (const line of lines) {
|
|
252
|
+
const effectiveQty = line.effectiveQtyPerUnit();
|
|
253
|
+
const resolved = await this.resolveCost(line.componentSkuId);
|
|
254
|
+
const unitCost = resolved ?? 0;
|
|
255
|
+
const costUnavailable = resolved === null || resolved === void 0;
|
|
256
|
+
const lineCost = unitCost * effectiveQty;
|
|
257
|
+
if (costUnavailable) {
|
|
258
|
+
hasMissingCosts = true;
|
|
259
|
+
} else {
|
|
260
|
+
totalCost += lineCost;
|
|
261
|
+
}
|
|
262
|
+
lineBreakdown.push({
|
|
263
|
+
componentSkuId: line.componentSkuId,
|
|
264
|
+
qtyPerUnit: Number(line.qtyPerUnit ?? 0),
|
|
265
|
+
wastePercent: Number(line.wastePercent ?? 0),
|
|
266
|
+
effectiveQty,
|
|
267
|
+
unitCost,
|
|
268
|
+
lineCost,
|
|
269
|
+
uom: line.uom,
|
|
270
|
+
costUnavailable
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
bomId,
|
|
275
|
+
totalCost,
|
|
276
|
+
currency: bom.currency || "USD",
|
|
277
|
+
lineBreakdown,
|
|
278
|
+
hasMissingCosts
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Return a "shopping list" of materials needed to produce `qty` units of
|
|
283
|
+
* the parent product against the given BOM. Lines that reference the
|
|
284
|
+
* same component SKU are summed so each `componentSkuId` appears once
|
|
285
|
+
* in the result.
|
|
286
|
+
*
|
|
287
|
+
* Does NOT mutate stock — purely a planning helper. Use
|
|
288
|
+
* {@link ProductionService.consumeMaterials} when you're ready to write
|
|
289
|
+
* stock movements.
|
|
290
|
+
*
|
|
291
|
+
* Throws {@link BomNotFoundError} when the BOM does not exist; throws a
|
|
292
|
+
* plain `Error` when `qty` is not a positive finite number.
|
|
293
|
+
*/
|
|
294
|
+
async explodeRequirements(bomId, qty) {
|
|
295
|
+
assertPositiveQty$1(qty, "explodeRequirements");
|
|
296
|
+
await this.requireBom(bomId);
|
|
297
|
+
const lines = await this.lines.findByBom(bomId);
|
|
298
|
+
const aggregate = /* @__PURE__ */ new Map();
|
|
299
|
+
for (const line of lines) {
|
|
300
|
+
const effectiveQty = line.effectiveQtyPerUnit() * qty;
|
|
301
|
+
const existing = aggregate.get(line.componentSkuId);
|
|
302
|
+
if (existing) {
|
|
303
|
+
existing.totalQty += effectiveQty;
|
|
304
|
+
} else {
|
|
305
|
+
aggregate.set(line.componentSkuId, {
|
|
306
|
+
componentSkuId: line.componentSkuId,
|
|
307
|
+
totalQty: effectiveQty,
|
|
308
|
+
uom: line.uom
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return Array.from(aggregate.values());
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Check whether the requirements for producing `qty` units against the
|
|
316
|
+
* given BOM are currently satisfied by available stock. Returns
|
|
317
|
+
* `{ ok: true, shortages: [] }` when every component has enough
|
|
318
|
+
* `available` stock across all locations; `{ ok: false, shortages: [...] }`
|
|
319
|
+
* with one entry per insufficient component otherwise.
|
|
320
|
+
*
|
|
321
|
+
* Available stock is summed across every location (the planning
|
|
322
|
+
* question is "do we have it at all?"; the operational question of
|
|
323
|
+
* "where do we pull from?" is left to the caller of
|
|
324
|
+
* {@link ProductionService.consumeMaterials}).
|
|
325
|
+
*
|
|
326
|
+
* Throws {@link BomNotFoundError} when the BOM does not exist.
|
|
327
|
+
*/
|
|
328
|
+
async canProduce(bomId, qty) {
|
|
329
|
+
const requirements = await this.explodeRequirements(bomId, qty);
|
|
330
|
+
const availabilities = await Promise.all(
|
|
331
|
+
requirements.map(async (requirement) => ({
|
|
332
|
+
requirement,
|
|
333
|
+
available: await this.stockService.levels.totalForSku(
|
|
334
|
+
requirement.componentSkuId,
|
|
335
|
+
"available"
|
|
336
|
+
)
|
|
337
|
+
}))
|
|
338
|
+
);
|
|
339
|
+
const shortages = [];
|
|
340
|
+
for (const { requirement, available } of availabilities) {
|
|
341
|
+
if (available < requirement.totalQty) {
|
|
342
|
+
shortages.push({
|
|
343
|
+
componentSkuId: requirement.componentSkuId,
|
|
344
|
+
requested: requirement.totalQty,
|
|
345
|
+
available
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (shortages.length === 0) {
|
|
350
|
+
return { ok: true, shortages: [] };
|
|
351
|
+
}
|
|
352
|
+
return { ok: false, shortages };
|
|
353
|
+
}
|
|
354
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
355
|
+
// Internal helpers
|
|
356
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
357
|
+
/**
|
|
358
|
+
* Fetch a BOM by id or throw {@link BomNotFoundError}. Centralises the
|
|
359
|
+
* "missing BOM" error path so callers don't have to repeat the check.
|
|
360
|
+
*/
|
|
361
|
+
async requireBom(bomId) {
|
|
362
|
+
if (!bomId) throw new BomNotFoundError(bomId);
|
|
363
|
+
const bom = await this.boms.get(bomId);
|
|
364
|
+
if (!bom) throw new BomNotFoundError(bomId);
|
|
365
|
+
return bom;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Run the supplied {@link ComponentCostResolver} for the given component
|
|
369
|
+
* SKU. Returns `null` when no resolver is registered or when the
|
|
370
|
+
* resolver returns `null` / `undefined`.
|
|
371
|
+
*/
|
|
372
|
+
async resolveCost(componentSkuId) {
|
|
373
|
+
if (!this.costResolver) return null;
|
|
374
|
+
const value = await this.costResolver(componentSkuId);
|
|
375
|
+
if (value === null || value === void 0) return null;
|
|
376
|
+
if (!Number.isFinite(value)) return null;
|
|
377
|
+
return Number(value);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function assertPositiveQty$1(qty, op) {
|
|
381
|
+
if (!Number.isFinite(qty) || qty <= 0) {
|
|
382
|
+
throw new Error(`${op}: qty must be a positive finite number (got ${qty})`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
async function buildStockService$1(options) {
|
|
386
|
+
if (!options.db) {
|
|
387
|
+
throw new Error(
|
|
388
|
+
"BomService.create: either `db` or `stockService` is required"
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return createStockService({ db: options.db });
|
|
392
|
+
}
|
|
393
|
+
async function createBomService(options) {
|
|
394
|
+
return BomService.create(options);
|
|
395
|
+
}
|
|
396
|
+
class ProductionService {
|
|
397
|
+
constructor(boms, lines, stockService) {
|
|
398
|
+
this.boms = boms;
|
|
399
|
+
this.lines = lines;
|
|
400
|
+
this.stockService = stockService;
|
|
401
|
+
}
|
|
402
|
+
boms;
|
|
403
|
+
lines;
|
|
404
|
+
stockService;
|
|
405
|
+
/** Factory — prefer {@link createProductionService}. */
|
|
406
|
+
static async create(options) {
|
|
407
|
+
const stockService = options.stockService ?? await buildStockService(options);
|
|
408
|
+
const sharedDb = options.db ?? stockService.db;
|
|
409
|
+
const [boms, lines] = await Promise.all([
|
|
410
|
+
BillOfMaterialsCollection.create({ db: sharedDb }),
|
|
411
|
+
BomLineCollection.create({ db: sharedDb })
|
|
412
|
+
]);
|
|
413
|
+
return new ProductionService(boms, lines, stockService);
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Walk every BOM line for the production order's BOM and deduct
|
|
417
|
+
* `effectiveQtyPerUnit() * qty` from `available` at `locationId`. Each
|
|
418
|
+
* deduction goes through {@link StockService.adjust} with `sourceType:
|
|
419
|
+
* 'ProductionOrder'` and `sourceId: order.id`. Returns the list of
|
|
420
|
+
* movements emitted so callers can log / surface them.
|
|
421
|
+
*
|
|
422
|
+
* **Atomic across lines**: all per-line deductions and their audit
|
|
423
|
+
* movements run inside a single `stockService.withTransaction(...)`
|
|
424
|
+
* scope. An `InsufficientStockError` on line N+1 rolls back lines 1..N
|
|
425
|
+
* so production-order posting never leaves materials half-consumed.
|
|
426
|
+
* Pre-flight with {@link BomService.canProduce} when you'd rather know
|
|
427
|
+
* upfront than discover the shortfall mid-run.
|
|
428
|
+
*
|
|
429
|
+
* Throws {@link NoActiveBomForProductError} if no BOM id is supplied
|
|
430
|
+
* on the order *and* no active BOM exists for the order's `productId`.
|
|
431
|
+
* Throws {@link BomNotFoundError} if `order.bomId` is supplied but
|
|
432
|
+
* doesn't resolve. Throws plain `Error` on missing `locationId`,
|
|
433
|
+
* non-positive `qty`, or when both `order.bomId` and `order.productId`
|
|
434
|
+
* are absent (a programmer error — neither input identifies a BOM).
|
|
435
|
+
* Re-throws `InsufficientStockError` from `StockService.adjust` if a
|
|
436
|
+
* line would drive `available` below zero.
|
|
437
|
+
*/
|
|
438
|
+
async consumeMaterials(order, options) {
|
|
439
|
+
const ctx = await this.prepareConsume(order, options);
|
|
440
|
+
return this.stockService.withTransaction(
|
|
441
|
+
async (tx) => this.consumeMaterialsWith(tx, ctx)
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Receive `qty` of the finished SKU into `available` at `locationId`.
|
|
446
|
+
* Goes through {@link StockService.receive} with `sourceType:
|
|
447
|
+
* 'ProductionOrder'` and `sourceId: order.id`. Returns a single
|
|
448
|
+
* {@link ProduceResult} describing what landed.
|
|
449
|
+
*
|
|
450
|
+
* Throws plain `Error` on missing required arguments or non-positive
|
|
451
|
+
* `qty`.
|
|
452
|
+
*/
|
|
453
|
+
async produceFinishedGoods(order, options) {
|
|
454
|
+
const ctx = prepareProduce(order, options);
|
|
455
|
+
return this.stockService.withTransaction(
|
|
456
|
+
async (tx) => this.produceFinishedGoodsWith(tx, ctx)
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Atomically consume materials AND receive finished goods for one
|
|
461
|
+
* production run.
|
|
462
|
+
*
|
|
463
|
+
* Both calls share a single `stockService.withTransaction(...)` scope:
|
|
464
|
+
*
|
|
465
|
+
* - Materials are deducted line-by-line via {@link StockService.adjust}.
|
|
466
|
+
* - Finished goods are received via {@link StockService.receive}.
|
|
467
|
+
* - If ANY step throws — a BOM-line shortage, the produce-leg adapter
|
|
468
|
+
* failing, a tenancy mismatch surfaced by an interceptor — every
|
|
469
|
+
* write in the transaction rolls back. The materials are restored
|
|
470
|
+
* and the finished SKU's `available` row is unchanged. Callers
|
|
471
|
+
* never observe "materials deducted but finished goods missing".
|
|
472
|
+
*
|
|
473
|
+
* Use this when consume + produce should be one indivisible event —
|
|
474
|
+
* e.g. a make-to-stock workflow posting `production_order:completed`
|
|
475
|
+
* where the shop floor step is invisible to the ledger.
|
|
476
|
+
*
|
|
477
|
+
* The two-call form (call {@link consumeMaterials} then {@link
|
|
478
|
+
* produceFinishedGoods} separately) is still appropriate when the
|
|
479
|
+
* factory step is a real wall-clock gap that other observers need to
|
|
480
|
+
* see — work-in-progress dashboards, partial-run reporting, etc.
|
|
481
|
+
*
|
|
482
|
+
* Throws the same errors as the individual methods: missing args
|
|
483
|
+
* (plain `Error`), unresolvable BOM ({@link BomNotFoundError} /
|
|
484
|
+
* {@link NoActiveBomForProductError}), or `InsufficientStockError`
|
|
485
|
+
* if a line would drive `available` below zero.
|
|
486
|
+
*/
|
|
487
|
+
async runProduction(order, options) {
|
|
488
|
+
const consumeCtx = await this.prepareConsume(order, options.consume);
|
|
489
|
+
const produceCtx = prepareProduce(order, options.produce);
|
|
490
|
+
return this.stockService.withTransaction(async (tx) => {
|
|
491
|
+
const consumed = await this.consumeMaterialsWith(tx, consumeCtx);
|
|
492
|
+
const produced = await this.produceFinishedGoodsWith(tx, produceCtx);
|
|
493
|
+
return { consumed, produced };
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
497
|
+
// Internal helpers
|
|
498
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
499
|
+
/**
|
|
500
|
+
* Validate consume args, resolve the BOM + lines, and pre-compute the
|
|
501
|
+
* mutation attribution. Anything that can throw without a tx open
|
|
502
|
+
* happens here so the transaction we open later doesn't have to roll
|
|
503
|
+
* back over a validation miss.
|
|
504
|
+
*/
|
|
505
|
+
async prepareConsume(order, options) {
|
|
506
|
+
assertLocationId(options.locationId, "consumeMaterials");
|
|
507
|
+
assertPositiveQty(options.qty, "consumeMaterials");
|
|
508
|
+
const orderId = order.id ?? "";
|
|
509
|
+
if (!orderId) {
|
|
510
|
+
throw new Error(
|
|
511
|
+
"consumeMaterials: order.id is required for source attribution"
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
const bom = await this.resolveBom(order);
|
|
515
|
+
const lines = await this.lines.findByBom(bom.id);
|
|
516
|
+
return {
|
|
517
|
+
orderId,
|
|
518
|
+
lines,
|
|
519
|
+
runQty: options.qty,
|
|
520
|
+
locationId: options.locationId,
|
|
521
|
+
mutationOptions: {
|
|
522
|
+
sourceType: "ProductionOrder",
|
|
523
|
+
sourceId: orderId,
|
|
524
|
+
reasonCode: options.reasonCode ?? "production_consume",
|
|
525
|
+
note: options.note
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Execute the consume leg against a caller-supplied tx-bound
|
|
531
|
+
* {@link StockService}. Public entry points (`consumeMaterials`,
|
|
532
|
+
* `runProduction`) open the tx and call through here. Each per-line
|
|
533
|
+
* `tx.adjust(...)` shares the same transaction so a shortfall on
|
|
534
|
+
* line N+1 rolls back lines 1..N.
|
|
535
|
+
*/
|
|
536
|
+
async consumeMaterialsWith(tx, ctx) {
|
|
537
|
+
const emitted = [];
|
|
538
|
+
for (const line of ctx.lines) {
|
|
539
|
+
const qty = line.effectiveQtyPerUnit() * ctx.runQty;
|
|
540
|
+
if (qty <= 0) continue;
|
|
541
|
+
await tx.adjust(
|
|
542
|
+
line.componentSkuId,
|
|
543
|
+
ctx.locationId,
|
|
544
|
+
-qty,
|
|
545
|
+
ctx.mutationOptions
|
|
546
|
+
);
|
|
547
|
+
emitted.push({
|
|
548
|
+
componentSkuId: line.componentSkuId,
|
|
549
|
+
qty,
|
|
550
|
+
locationId: ctx.locationId
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return emitted;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Execute the produce leg against a caller-supplied tx-bound
|
|
557
|
+
* {@link StockService}. Public entry points (`produceFinishedGoods`,
|
|
558
|
+
* `runProduction`) open the tx and call through here.
|
|
559
|
+
*/
|
|
560
|
+
async produceFinishedGoodsWith(tx, ctx) {
|
|
561
|
+
await tx.receive(
|
|
562
|
+
ctx.finishedSkuId,
|
|
563
|
+
ctx.locationId,
|
|
564
|
+
ctx.qty,
|
|
565
|
+
ctx.mutationOptions
|
|
566
|
+
);
|
|
567
|
+
return {
|
|
568
|
+
finishedSkuId: ctx.finishedSkuId,
|
|
569
|
+
qty: ctx.qty,
|
|
570
|
+
locationId: ctx.locationId
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Resolve the BOM for a production order. Order of preference:
|
|
575
|
+
*
|
|
576
|
+
* 1. Explicit `order.bomId` if supplied — the caller pinned a specific
|
|
577
|
+
* revision; we honor that pin or fail. We deliberately do NOT fall
|
|
578
|
+
* back to the active BOM when `order.bomId` is stale or mistyped,
|
|
579
|
+
* because silently switching recipes would have the production run
|
|
580
|
+
* consume materials against a different BOM than the order locked.
|
|
581
|
+
* 2. The active BOM for `order.productId`.
|
|
582
|
+
*
|
|
583
|
+
* Throws {@link BomNotFoundError} when an explicit `order.bomId` is
|
|
584
|
+
* supplied but doesn't resolve to a row.
|
|
585
|
+
*
|
|
586
|
+
* Throws {@link NoActiveBomForProductError} when no `bomId` was supplied
|
|
587
|
+
* and the product has no active BOM.
|
|
588
|
+
*
|
|
589
|
+
* Throws plain `Error` when neither `order.bomId` nor `order.productId`
|
|
590
|
+
* is supplied — there's nothing to resolve against, so we surface that
|
|
591
|
+
* as a programmer error with a distinct message rather than overload
|
|
592
|
+
* `NoActiveBomForProductError` with an empty product id.
|
|
593
|
+
*/
|
|
594
|
+
async resolveBom(order) {
|
|
595
|
+
if (order.bomId) {
|
|
596
|
+
const bom = await this.boms.get(order.bomId);
|
|
597
|
+
if (!bom) throw new BomNotFoundError(order.bomId);
|
|
598
|
+
return bom;
|
|
599
|
+
}
|
|
600
|
+
const productId = order.productId ?? "";
|
|
601
|
+
if (!productId) {
|
|
602
|
+
throw new Error(
|
|
603
|
+
"consumeMaterials: order.productId is required when order.bomId is not supplied"
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
const active = await this.boms.findActiveForProduct(productId);
|
|
607
|
+
if (!active) throw new NoActiveBomForProductError(productId);
|
|
608
|
+
return active;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
function prepareProduce(order, options) {
|
|
612
|
+
assertLocationId(options.locationId, "produceFinishedGoods");
|
|
613
|
+
assertPositiveQty(options.qty, "produceFinishedGoods");
|
|
614
|
+
if (!options.finishedSkuId) {
|
|
615
|
+
throw new Error(
|
|
616
|
+
"produceFinishedGoods: finishedSkuId is required (the production order references a productId; the caller picks which SKU is being produced)"
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
const orderId = order.id ?? "";
|
|
620
|
+
if (!orderId) {
|
|
621
|
+
throw new Error(
|
|
622
|
+
"produceFinishedGoods: order.id is required for source attribution"
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
orderId,
|
|
627
|
+
finishedSkuId: options.finishedSkuId,
|
|
628
|
+
qty: options.qty,
|
|
629
|
+
locationId: options.locationId,
|
|
630
|
+
mutationOptions: {
|
|
631
|
+
sourceType: "ProductionOrder",
|
|
632
|
+
sourceId: orderId,
|
|
633
|
+
reasonCode: options.reasonCode ?? "production_produce",
|
|
634
|
+
note: options.note
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
function assertPositiveQty(qty, op) {
|
|
639
|
+
if (!Number.isFinite(qty) || qty <= 0) {
|
|
640
|
+
throw new Error(`${op}: qty must be a positive finite number (got ${qty})`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
function assertLocationId(locationId, op) {
|
|
644
|
+
if (!locationId || typeof locationId !== "string") {
|
|
645
|
+
throw new Error(`${op}: locationId is required`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
async function buildStockService(options) {
|
|
649
|
+
if (!options.db) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
"ProductionService.create: either `db` or `stockService` is required"
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
return createStockService({ db: options.db });
|
|
655
|
+
}
|
|
656
|
+
async function createProductionService(options) {
|
|
657
|
+
return ProductionService.create(options);
|
|
658
|
+
}
|
|
659
|
+
const logger = createLogger({ level: "info" });
|
|
660
|
+
async function installManufacturingDispatchHandlers(options) {
|
|
661
|
+
const {
|
|
662
|
+
dispatchBus,
|
|
663
|
+
installProductionPosted = true,
|
|
664
|
+
installProductionCompleted = false,
|
|
665
|
+
producedOnPosted = false
|
|
666
|
+
} = options;
|
|
667
|
+
const productionService = options.productionService ?? await buildProductionService(options);
|
|
668
|
+
const installed = [];
|
|
669
|
+
if (installProductionPosted) {
|
|
670
|
+
const shouldProduceOnPosted = producedOnPosted && !installProductionCompleted;
|
|
671
|
+
const handler = async (payload, metadata) => {
|
|
672
|
+
await handleProductionPosted(
|
|
673
|
+
productionService,
|
|
674
|
+
payload,
|
|
675
|
+
metadata,
|
|
676
|
+
shouldProduceOnPosted
|
|
677
|
+
);
|
|
678
|
+
};
|
|
679
|
+
dispatchBus.on("production_order:posted", handler);
|
|
680
|
+
installed.push({ pattern: "production_order:posted", handler });
|
|
681
|
+
}
|
|
682
|
+
if (installProductionCompleted) {
|
|
683
|
+
const handler = async (payload, metadata) => {
|
|
684
|
+
await handleProductionCompleted(
|
|
685
|
+
productionService,
|
|
686
|
+
payload,
|
|
687
|
+
metadata
|
|
688
|
+
);
|
|
689
|
+
};
|
|
690
|
+
dispatchBus.on("production_order:completed", handler);
|
|
691
|
+
installed.push({ pattern: "production_order:completed", handler });
|
|
692
|
+
}
|
|
693
|
+
return {
|
|
694
|
+
productionService,
|
|
695
|
+
dispose() {
|
|
696
|
+
for (const entry of installed) {
|
|
697
|
+
dispatchBus.off(entry.pattern, entry.handler);
|
|
698
|
+
}
|
|
699
|
+
installed.length = 0;
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
async function handleProductionPosted(productionService, payload, metadata, shouldProduce) {
|
|
704
|
+
const postedReason = malformedProductionPayloadReason(payload);
|
|
705
|
+
if (postedReason || !payload) {
|
|
706
|
+
warnMalformedPayload(
|
|
707
|
+
"production_order:posted",
|
|
708
|
+
payload,
|
|
709
|
+
metadata,
|
|
710
|
+
postedReason ?? "payload is null/undefined"
|
|
711
|
+
);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (shouldProduce && !payload.finishedSkuId) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
`production_order:posted handler: producedOnPosted is enabled but payload.finishedSkuId is missing (productionOrderId=${payload.productionOrderId}). Either supply finishedSkuId on the event, or disable producedOnPosted and emit production_order:completed separately.`
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
const order = {
|
|
720
|
+
id: payload.productionOrderId,
|
|
721
|
+
productId: payload.productId,
|
|
722
|
+
bomId: payload.bomId
|
|
723
|
+
};
|
|
724
|
+
const consumeNote = metadata.source ? `auto-consume via ${metadata.source}` : void 0;
|
|
725
|
+
if (shouldProduce && payload.finishedSkuId) {
|
|
726
|
+
const produceNote = metadata.source ? `auto-produce via ${metadata.source}` : void 0;
|
|
727
|
+
await productionService.runProduction(order, {
|
|
728
|
+
consume: {
|
|
729
|
+
locationId: payload.locationId,
|
|
730
|
+
qty: payload.qty,
|
|
731
|
+
note: consumeNote
|
|
732
|
+
},
|
|
733
|
+
produce: {
|
|
734
|
+
locationId: payload.locationId,
|
|
735
|
+
qty: payload.qty,
|
|
736
|
+
finishedSkuId: payload.finishedSkuId,
|
|
737
|
+
note: produceNote
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
await productionService.consumeMaterials(order, {
|
|
743
|
+
locationId: payload.locationId,
|
|
744
|
+
qty: payload.qty,
|
|
745
|
+
note: consumeNote
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
async function handleProductionCompleted(productionService, payload, metadata) {
|
|
749
|
+
const completedReason = malformedProductionPayloadReason(payload);
|
|
750
|
+
if (completedReason || !payload) {
|
|
751
|
+
warnMalformedPayload(
|
|
752
|
+
"production_order:completed",
|
|
753
|
+
payload,
|
|
754
|
+
metadata,
|
|
755
|
+
completedReason ?? "payload is null/undefined"
|
|
756
|
+
);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
await productionService.produceFinishedGoods(
|
|
760
|
+
{ id: payload.productionOrderId },
|
|
761
|
+
{
|
|
762
|
+
locationId: payload.locationId,
|
|
763
|
+
qty: payload.qty,
|
|
764
|
+
finishedSkuId: payload.finishedSkuId,
|
|
765
|
+
note: metadata.source ? `auto-produce via ${metadata.source}` : void 0
|
|
766
|
+
}
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
function malformedProductionPayloadReason(payload) {
|
|
770
|
+
if (!payload || typeof payload !== "object") {
|
|
771
|
+
return "payload is null/undefined or not an object";
|
|
772
|
+
}
|
|
773
|
+
if (!payload.productionOrderId || typeof payload.productionOrderId !== "string") {
|
|
774
|
+
return "missing or non-string `productionOrderId` (required for audit-trail source attribution)";
|
|
775
|
+
}
|
|
776
|
+
return null;
|
|
777
|
+
}
|
|
778
|
+
function warnMalformedPayload(signal, payload, metadata, reason) {
|
|
779
|
+
logger.warn(
|
|
780
|
+
`[@happyvertical/smrt-manufacturing] dispatch handler ignored a ${signal} event with a malformed payload (${reason}). Source: ${metadata.source ?? "<unknown>"}; payload keys: ${payload && typeof payload === "object" ? Object.keys(payload).join(",") : typeof payload}`
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
async function buildProductionService(options) {
|
|
784
|
+
if (options.stockService) {
|
|
785
|
+
return createProductionService({
|
|
786
|
+
stockService: options.stockService,
|
|
787
|
+
...options.db ? { db: options.db } : {}
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
if (!options.db) {
|
|
791
|
+
throw new Error(
|
|
792
|
+
"installManufacturingDispatchHandlers: one of `productionService`, `stockService`, or `db` is required"
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
return createProductionService({ db: options.db });
|
|
796
|
+
}
|
|
797
|
+
export {
|
|
798
|
+
BillOfMaterials,
|
|
799
|
+
BillOfMaterialsCollection,
|
|
800
|
+
BomLine,
|
|
801
|
+
BomLineCollection,
|
|
802
|
+
BomNotFoundError,
|
|
803
|
+
BomService,
|
|
804
|
+
NoActiveBomForProductError,
|
|
805
|
+
ProductionService,
|
|
806
|
+
createBomService,
|
|
807
|
+
createProductionService,
|
|
808
|
+
installManufacturingDispatchHandlers
|
|
809
|
+
};
|
|
810
|
+
//# sourceMappingURL=index.js.map
|