@happyvertical/smrt-manufacturing 0.37.2 → 0.37.4

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