@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/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