@happyvertical/smrt-inventory 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +121 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +213 -0
- package/dist/index.d.ts +557 -0
- package/dist/index.js +922 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.json +1023 -0
- package/dist/smrt-knowledge.json +618 -0
- package/dist/types.d.ts +55 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +64 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
import { ObjectRegistry, field, smrt, SmrtObject, SmrtCollection, resolveDatabase } from "@happyvertical/smrt-core";
|
|
2
|
+
import { tenantId, TenantScoped } from "@happyvertical/smrt-tenancy";
|
|
3
|
+
import { createLogger } from "@happyvertical/logger";
|
|
4
|
+
ObjectRegistry.registerPackageManifest(
|
|
5
|
+
new URL("./manifest.json", import.meta.url)
|
|
6
|
+
);
|
|
7
|
+
var __defProp$2 = Object.defineProperty;
|
|
8
|
+
var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
|
|
9
|
+
var __decorateClass$2 = (decorators, target, key, kind) => {
|
|
10
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$2(target, key) : target;
|
|
11
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
12
|
+
if (decorator = decorators[i])
|
|
13
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
14
|
+
if (kind && result) __defProp$2(target, key, result);
|
|
15
|
+
return result;
|
|
16
|
+
};
|
|
17
|
+
let InventoryLocation = class extends SmrtObject {
|
|
18
|
+
tenantId = null;
|
|
19
|
+
code = "";
|
|
20
|
+
/** Display name for UIs. */
|
|
21
|
+
name = "";
|
|
22
|
+
/**
|
|
23
|
+
* Open-ended classifier (`'warehouse'`, `'factory'`, `'retail'`,
|
|
24
|
+
* `'in_transit'`, `'virtual'`, or anything else your domain needs).
|
|
25
|
+
* The framework never branches on this value.
|
|
26
|
+
*/
|
|
27
|
+
kind = "warehouse";
|
|
28
|
+
/**
|
|
29
|
+
* Optional plain-string reference to a `Place.id` in
|
|
30
|
+
* `@happyvertical/smrt-places`. Cross-package id; intentionally not a
|
|
31
|
+
* `@foreignKey()` so this package can be used without `smrt-places`
|
|
32
|
+
* installed.
|
|
33
|
+
*/
|
|
34
|
+
placeId = "";
|
|
35
|
+
/** Soft-active flag — inactive locations stay queryable for history. */
|
|
36
|
+
active = true;
|
|
37
|
+
constructor(options = {}) {
|
|
38
|
+
super(options);
|
|
39
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
40
|
+
if (options.code !== void 0) this.code = options.code;
|
|
41
|
+
if (options.name !== void 0) this.name = options.name;
|
|
42
|
+
if (options.kind !== void 0) this.kind = options.kind;
|
|
43
|
+
if (options.placeId !== void 0) this.placeId = options.placeId;
|
|
44
|
+
if (options.active !== void 0) this.active = options.active;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
__decorateClass$2([
|
|
48
|
+
tenantId({ nullable: true })
|
|
49
|
+
], InventoryLocation.prototype, "tenantId", 2);
|
|
50
|
+
__decorateClass$2([
|
|
51
|
+
field({ required: true })
|
|
52
|
+
], InventoryLocation.prototype, "code", 2);
|
|
53
|
+
InventoryLocation = __decorateClass$2([
|
|
54
|
+
TenantScoped({ mode: "optional" }),
|
|
55
|
+
smrt({
|
|
56
|
+
tableName: "inventory_locations",
|
|
57
|
+
conflictColumns: ["code", "tenant_id"],
|
|
58
|
+
api: { include: ["list", "get", "create", "update"] },
|
|
59
|
+
mcp: { include: ["list", "get"] },
|
|
60
|
+
cli: true
|
|
61
|
+
})
|
|
62
|
+
], InventoryLocation);
|
|
63
|
+
class InventoryLocationCollection extends SmrtCollection {
|
|
64
|
+
static _itemClass = InventoryLocation;
|
|
65
|
+
/**
|
|
66
|
+
* Look up a location by its tenant-scoped `code`. Returns `null` when
|
|
67
|
+
* no row matches.
|
|
68
|
+
*/
|
|
69
|
+
async findByCode(code) {
|
|
70
|
+
const matches = await this.list({ where: { code }, limit: 1 });
|
|
71
|
+
return matches[0] ?? null;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Find every location classified as the given kind (`'warehouse'`,
|
|
75
|
+
* `'factory'`, `'retail'`, `'in_transit'`, …).
|
|
76
|
+
*/
|
|
77
|
+
async findByKind(kind) {
|
|
78
|
+
return this.list({ where: { kind }, orderBy: "code ASC" });
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Find every location linked to a particular `Place.id` from
|
|
82
|
+
* `@happyvertical/smrt-places`. Returns an empty array when no row
|
|
83
|
+
* references the place.
|
|
84
|
+
*/
|
|
85
|
+
async findByPlace(placeId) {
|
|
86
|
+
if (!placeId) return [];
|
|
87
|
+
return this.list({ where: { placeId }, orderBy: "code ASC" });
|
|
88
|
+
}
|
|
89
|
+
/** Find every active location, optionally narrowed by kind. */
|
|
90
|
+
async findActive(kind) {
|
|
91
|
+
const where = { active: true };
|
|
92
|
+
if (kind) where.kind = kind;
|
|
93
|
+
return this.list({ where, orderBy: "code ASC" });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
var __defProp$1 = Object.defineProperty;
|
|
97
|
+
var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
|
|
98
|
+
var __decorateClass$1 = (decorators, target, key, kind) => {
|
|
99
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
|
|
100
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
101
|
+
if (decorator = decorators[i])
|
|
102
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
103
|
+
if (kind && result) __defProp$1(target, key, result);
|
|
104
|
+
return result;
|
|
105
|
+
};
|
|
106
|
+
let StockLevel = class extends SmrtObject {
|
|
107
|
+
tenantId = null;
|
|
108
|
+
skuId = "";
|
|
109
|
+
locationId = "";
|
|
110
|
+
state = "available";
|
|
111
|
+
qty = 0;
|
|
112
|
+
constructor(options = {}) {
|
|
113
|
+
super(options);
|
|
114
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
115
|
+
if (options.skuId !== void 0) this.skuId = options.skuId;
|
|
116
|
+
if (options.locationId !== void 0) this.locationId = options.locationId;
|
|
117
|
+
if (options.state !== void 0) this.state = options.state;
|
|
118
|
+
if (options.qty !== void 0) this.qty = options.qty;
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
__decorateClass$1([
|
|
122
|
+
tenantId({ nullable: true })
|
|
123
|
+
], StockLevel.prototype, "tenantId", 2);
|
|
124
|
+
__decorateClass$1([
|
|
125
|
+
field({ required: true })
|
|
126
|
+
], StockLevel.prototype, "skuId", 2);
|
|
127
|
+
__decorateClass$1([
|
|
128
|
+
field({ required: true })
|
|
129
|
+
], StockLevel.prototype, "locationId", 2);
|
|
130
|
+
__decorateClass$1([
|
|
131
|
+
field({ required: true })
|
|
132
|
+
], StockLevel.prototype, "state", 2);
|
|
133
|
+
__decorateClass$1([
|
|
134
|
+
field({ type: "decimal" })
|
|
135
|
+
], StockLevel.prototype, "qty", 2);
|
|
136
|
+
StockLevel = __decorateClass$1([
|
|
137
|
+
TenantScoped({ mode: "optional" }),
|
|
138
|
+
smrt({
|
|
139
|
+
tableName: "inventory_stock_levels",
|
|
140
|
+
conflictColumns: ["sku_id", "location_id", "state", "tenant_id"],
|
|
141
|
+
// StockLevel is materialized state, written only by StockService.
|
|
142
|
+
// Mirror the read-only api/mcp posture on the CLI — `cli: true` would
|
|
143
|
+
// generate create/update/delete subcommands that let a CLI user
|
|
144
|
+
// mutate `qty` (or delete a row) without writing a paired
|
|
145
|
+
// StockMovement, silently desyncing the audit ledger from the
|
|
146
|
+
// materialized balance. The Gotchas section in CLAUDE.md spells out
|
|
147
|
+
// the "never call StockLevel.save() directly" rule; the CLI must
|
|
148
|
+
// follow the same constraint.
|
|
149
|
+
api: { include: ["list", "get"] },
|
|
150
|
+
mcp: { include: ["list", "get"] },
|
|
151
|
+
cli: { include: ["list", "get"] }
|
|
152
|
+
})
|
|
153
|
+
], StockLevel);
|
|
154
|
+
class StockLevelCollection extends SmrtCollection {
|
|
155
|
+
static _itemClass = StockLevel;
|
|
156
|
+
/**
|
|
157
|
+
* Fetch the level row for a `(skuId, locationId, state)` tuple, or
|
|
158
|
+
* `null` when the row has never been written. State defaults to
|
|
159
|
+
* `'available'` because that is the common case (selling / picking
|
|
160
|
+
* decisions are driven by available stock).
|
|
161
|
+
*/
|
|
162
|
+
async getLevel(skuId, locationId, state = "available") {
|
|
163
|
+
const matches = await this.list({
|
|
164
|
+
where: { skuId, locationId, state },
|
|
165
|
+
limit: 1
|
|
166
|
+
});
|
|
167
|
+
return matches[0] ?? null;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Return every level row for the given SKU across all locations and
|
|
171
|
+
* states. Useful for "where is this SKU?" admin screens.
|
|
172
|
+
*/
|
|
173
|
+
async findBySku(skuId) {
|
|
174
|
+
return this.list({ where: { skuId } });
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Return every level row at the given location. Useful for "what is
|
|
178
|
+
* in this warehouse?" reports.
|
|
179
|
+
*/
|
|
180
|
+
async findByLocation(locationId) {
|
|
181
|
+
return this.list({ where: { locationId } });
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Sum `qty` across all level rows for the given SKU. Pass `state` to
|
|
185
|
+
* narrow the sum to one logical state (e.g. only `available`);
|
|
186
|
+
* omit it for grand total across all states.
|
|
187
|
+
*/
|
|
188
|
+
async totalForSku(skuId, state) {
|
|
189
|
+
const where = { skuId };
|
|
190
|
+
if (state) where.state = state;
|
|
191
|
+
const levels = await this.list({ where });
|
|
192
|
+
return levels.reduce((sum, row) => sum + Number(row.qty ?? 0), 0);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Sum `qty` across all level rows at the given location, optionally
|
|
196
|
+
* narrowed by state.
|
|
197
|
+
*/
|
|
198
|
+
async totalForLocation(locationId, state) {
|
|
199
|
+
const where = { locationId };
|
|
200
|
+
if (state) where.state = state;
|
|
201
|
+
const levels = await this.list({ where });
|
|
202
|
+
return levels.reduce((sum, row) => sum + Number(row.qty ?? 0), 0);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
var __defProp = Object.defineProperty;
|
|
206
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
207
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
208
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
209
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--)
|
|
210
|
+
if (decorator = decorators[i])
|
|
211
|
+
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
212
|
+
if (kind && result) __defProp(target, key, result);
|
|
213
|
+
return result;
|
|
214
|
+
};
|
|
215
|
+
let StockMovement = class extends SmrtObject {
|
|
216
|
+
tenantId = null;
|
|
217
|
+
skuId = "";
|
|
218
|
+
locationId = "";
|
|
219
|
+
/**
|
|
220
|
+
* Origin state for transitions (e.g. `available` → `allocated` for a
|
|
221
|
+
* reservation). `null` indicates "no origin" — used when stock enters
|
|
222
|
+
* the system fresh via {@link StockService.receive} or production.
|
|
223
|
+
*/
|
|
224
|
+
fromState = null;
|
|
225
|
+
/**
|
|
226
|
+
* Destination state. `null` indicates "no destination" — used for
|
|
227
|
+
* fulfilment, where stock leaves the building entirely.
|
|
228
|
+
*/
|
|
229
|
+
toState = null;
|
|
230
|
+
qty = 0;
|
|
231
|
+
reasonCode = "adjustment";
|
|
232
|
+
/**
|
|
233
|
+
* Cross-package attribution tag — e.g. `'Contract'`, `'Fulfillment'`,
|
|
234
|
+
* `'ProductionOrder'`, `'CycleCount'`. The package writing the
|
|
235
|
+
* movement decides what tag makes sense; readers can group by
|
|
236
|
+
* `(sourceType, sourceId)` to reconstruct "what caused this".
|
|
237
|
+
*/
|
|
238
|
+
sourceType = "";
|
|
239
|
+
/**
|
|
240
|
+
* Cross-package id of the row that caused this movement. Plain string;
|
|
241
|
+
* the framework never dereferences it.
|
|
242
|
+
*/
|
|
243
|
+
sourceId = "";
|
|
244
|
+
/** Optional free-form note shown in audit UIs. */
|
|
245
|
+
note = "";
|
|
246
|
+
/**
|
|
247
|
+
* When the movement happened. Set to `now` at write time; explicit
|
|
248
|
+
* values are allowed when back-dating an import.
|
|
249
|
+
*/
|
|
250
|
+
occurredAt = /* @__PURE__ */ new Date();
|
|
251
|
+
constructor(options = {}) {
|
|
252
|
+
super(options);
|
|
253
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
254
|
+
if (options.skuId !== void 0) this.skuId = options.skuId;
|
|
255
|
+
if (options.locationId !== void 0) this.locationId = options.locationId;
|
|
256
|
+
if (options.fromState !== void 0) this.fromState = options.fromState;
|
|
257
|
+
if (options.toState !== void 0) this.toState = options.toState;
|
|
258
|
+
if (options.qty !== void 0) this.qty = options.qty;
|
|
259
|
+
if (options.reasonCode !== void 0) this.reasonCode = options.reasonCode;
|
|
260
|
+
if (options.sourceType !== void 0) this.sourceType = options.sourceType;
|
|
261
|
+
if (options.sourceId !== void 0) this.sourceId = options.sourceId;
|
|
262
|
+
if (options.note !== void 0) this.note = options.note;
|
|
263
|
+
if (options.occurredAt !== void 0) {
|
|
264
|
+
this.occurredAt = options.occurredAt instanceof Date ? options.occurredAt : new Date(options.occurredAt);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
__decorateClass([
|
|
269
|
+
tenantId({ nullable: true })
|
|
270
|
+
], StockMovement.prototype, "tenantId", 2);
|
|
271
|
+
__decorateClass([
|
|
272
|
+
field({ required: true })
|
|
273
|
+
], StockMovement.prototype, "skuId", 2);
|
|
274
|
+
__decorateClass([
|
|
275
|
+
field({ required: true })
|
|
276
|
+
], StockMovement.prototype, "locationId", 2);
|
|
277
|
+
__decorateClass([
|
|
278
|
+
field({ type: "decimal" })
|
|
279
|
+
], StockMovement.prototype, "qty", 2);
|
|
280
|
+
__decorateClass([
|
|
281
|
+
field({ required: true })
|
|
282
|
+
], StockMovement.prototype, "reasonCode", 2);
|
|
283
|
+
StockMovement = __decorateClass([
|
|
284
|
+
TenantScoped({ mode: "optional" }),
|
|
285
|
+
smrt({
|
|
286
|
+
tableName: "inventory_stock_movements",
|
|
287
|
+
// Append-only: the natural key is the surrogate id, so updates can
|
|
288
|
+
// never collide on conflictColumns. Setting `id` explicitly here
|
|
289
|
+
// documents the intent and prevents future contributors from
|
|
290
|
+
// accidentally adding a "domain natural key" that would let upserts
|
|
291
|
+
// overwrite history.
|
|
292
|
+
conflictColumns: ["id"],
|
|
293
|
+
// Movements are an audit log — never mutate or delete via generated
|
|
294
|
+
// CRUD. Reads are fine for reporting and admin tooling. CLI follows
|
|
295
|
+
// the same posture as api/mcp; `cli: true` would generate
|
|
296
|
+
// create/update/delete subcommands that could rewrite or remove
|
|
297
|
+
// audit rows from the shell, defeating the append-only invariant
|
|
298
|
+
// documented in CLAUDE.md.
|
|
299
|
+
api: { include: ["list", "get"] },
|
|
300
|
+
mcp: { include: ["list", "get"] },
|
|
301
|
+
cli: { include: ["list", "get"] }
|
|
302
|
+
})
|
|
303
|
+
], StockMovement);
|
|
304
|
+
class StockMovementCollection extends SmrtCollection {
|
|
305
|
+
static _itemClass = StockMovement;
|
|
306
|
+
/**
|
|
307
|
+
* Return every movement for the given SKU, newest first. Useful for a
|
|
308
|
+
* per-SKU audit trail.
|
|
309
|
+
*/
|
|
310
|
+
async findBySku(skuId) {
|
|
311
|
+
return this.list({ where: { skuId }, orderBy: "occurredAt DESC" });
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Return every movement at the given location, newest first. Useful
|
|
315
|
+
* for a per-warehouse audit trail.
|
|
316
|
+
*/
|
|
317
|
+
async findByLocation(locationId) {
|
|
318
|
+
return this.list({
|
|
319
|
+
where: { locationId },
|
|
320
|
+
orderBy: "occurredAt DESC"
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Return every movement attributed to the given upstream source — for
|
|
325
|
+
* example `findBySource('Contract', contract.id)` returns every
|
|
326
|
+
* movement caused by the reservation/fulfilment/release of that
|
|
327
|
+
* contract. Newest first.
|
|
328
|
+
*/
|
|
329
|
+
async findBySource(sourceType, sourceId) {
|
|
330
|
+
return this.list({
|
|
331
|
+
where: { sourceType, sourceId },
|
|
332
|
+
orderBy: "occurredAt DESC"
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Return every movement with the given reason code (`'receipt'`,
|
|
337
|
+
* `'reservation'`, `'adjustment'`, …). Newest first.
|
|
338
|
+
*/
|
|
339
|
+
async findByReason(reasonCode) {
|
|
340
|
+
return this.list({
|
|
341
|
+
where: { reasonCode },
|
|
342
|
+
orderBy: "occurredAt DESC"
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const logger$1 = createLogger({ level: "info" });
|
|
347
|
+
class InsufficientStockError extends Error {
|
|
348
|
+
constructor(skuId, locationId, state, requested, available) {
|
|
349
|
+
super(
|
|
350
|
+
`Insufficient stock: sku=${skuId} location=${locationId} state=${state} requested=${requested} available=${available}`
|
|
351
|
+
);
|
|
352
|
+
this.skuId = skuId;
|
|
353
|
+
this.locationId = locationId;
|
|
354
|
+
this.state = state;
|
|
355
|
+
this.requested = requested;
|
|
356
|
+
this.available = available;
|
|
357
|
+
}
|
|
358
|
+
skuId;
|
|
359
|
+
locationId;
|
|
360
|
+
state;
|
|
361
|
+
requested;
|
|
362
|
+
available;
|
|
363
|
+
name = "InsufficientStockError";
|
|
364
|
+
}
|
|
365
|
+
class StockService {
|
|
366
|
+
constructor(db, levels, movements, locations, inTransaction = false) {
|
|
367
|
+
this.db = db;
|
|
368
|
+
this.levels = levels;
|
|
369
|
+
this.movements = movements;
|
|
370
|
+
this.locations = locations;
|
|
371
|
+
this.inTransaction = inTransaction;
|
|
372
|
+
}
|
|
373
|
+
db;
|
|
374
|
+
levels;
|
|
375
|
+
movements;
|
|
376
|
+
locations;
|
|
377
|
+
inTransaction;
|
|
378
|
+
/** Internal factory — prefer {@link createStockService}. */
|
|
379
|
+
static async create(options) {
|
|
380
|
+
const resolved = await resolveDatabase(options.db);
|
|
381
|
+
const [levels, movements, locations] = await Promise.all([
|
|
382
|
+
StockLevelCollection.create({ db: resolved }),
|
|
383
|
+
StockMovementCollection.create({ db: resolved }),
|
|
384
|
+
InventoryLocationCollection.create({ db: resolved })
|
|
385
|
+
]);
|
|
386
|
+
return new StockService(
|
|
387
|
+
resolved,
|
|
388
|
+
levels,
|
|
389
|
+
movements,
|
|
390
|
+
locations
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Run `work` inside a single database transaction with a tx-bound
|
|
395
|
+
* {@link StockService} instance. All mutation calls on `tx` commit
|
|
396
|
+
* atomically when `work` resolves and roll back if it throws.
|
|
397
|
+
*
|
|
398
|
+
* Use this when you need atomicity ACROSS multiple stock-service
|
|
399
|
+
* calls — e.g. consuming materials for every line of a production
|
|
400
|
+
* order in `@happyvertical/smrt-manufacturing`'s `ProductionService`,
|
|
401
|
+
* or a custom workflow that reserves + fulfills + writes a custom
|
|
402
|
+
* audit comment in one indivisible step. Individual mutation methods
|
|
403
|
+
* (`receive`, `reserve`, etc.) are already atomic on their own — you
|
|
404
|
+
* only need `withTransaction` for cross-call composition.
|
|
405
|
+
*
|
|
406
|
+
* Nesting is safe: calling `tx.withTransaction(...)` inside an
|
|
407
|
+
* already-tx-bound callback simply runs the inner `work` on the same
|
|
408
|
+
* transaction without opening a savepoint.
|
|
409
|
+
*
|
|
410
|
+
* When the underlying adapter does not expose `transaction()`, falls
|
|
411
|
+
* through to a serial run on the regular collections with a one-time
|
|
412
|
+
* warning. All four built-in adapters in `@happyvertical/sql >= 0.74.0`
|
|
413
|
+
* support it; only test stubs would hit this branch.
|
|
414
|
+
*/
|
|
415
|
+
async withTransaction(work) {
|
|
416
|
+
if (this.inTransaction) return work(this);
|
|
417
|
+
const underlying = this.levels.db;
|
|
418
|
+
if (typeof underlying?.transaction !== "function") {
|
|
419
|
+
warnNonTransactional();
|
|
420
|
+
return work(this);
|
|
421
|
+
}
|
|
422
|
+
return underlying.transaction(async (txDb) => {
|
|
423
|
+
const [levels, movements, locations] = await Promise.all([
|
|
424
|
+
StockLevelCollection.create({ db: txDb }),
|
|
425
|
+
StockMovementCollection.create({ db: txDb }),
|
|
426
|
+
InventoryLocationCollection.create({ db: txDb })
|
|
427
|
+
]);
|
|
428
|
+
const tx = new StockService(
|
|
429
|
+
txDb,
|
|
430
|
+
levels,
|
|
431
|
+
movements,
|
|
432
|
+
locations,
|
|
433
|
+
/* inTransaction */
|
|
434
|
+
true
|
|
435
|
+
);
|
|
436
|
+
return work(tx);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Internal: run a single-method mutation in a transaction. If we're
|
|
441
|
+
* already inside one (the instance was handed to a `withTransaction`
|
|
442
|
+
* callback), reuse it; otherwise open a fresh one.
|
|
443
|
+
*/
|
|
444
|
+
async runAtomically(work) {
|
|
445
|
+
if (this.inTransaction) {
|
|
446
|
+
return work({ levels: this.levels, movements: this.movements });
|
|
447
|
+
}
|
|
448
|
+
return this.withTransaction(
|
|
449
|
+
async (tx) => work({ levels: tx.levels, movements: tx.movements })
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Add `qty` to available stock at the given location. Used for
|
|
454
|
+
* purchase-order receipts, customer returns going back into available
|
|
455
|
+
* inventory, and the "produce" leg of a production order.
|
|
456
|
+
*/
|
|
457
|
+
async receive(skuId, locationId, qty, options = {}) {
|
|
458
|
+
assertPositiveQty(qty, "receive");
|
|
459
|
+
await this.runAtomically(async (tx) => {
|
|
460
|
+
await adjustLevel(tx.levels, {
|
|
461
|
+
skuId,
|
|
462
|
+
locationId,
|
|
463
|
+
state: "available",
|
|
464
|
+
delta: qty
|
|
465
|
+
});
|
|
466
|
+
await writeMovement(tx.movements, {
|
|
467
|
+
skuId,
|
|
468
|
+
locationId,
|
|
469
|
+
fromState: null,
|
|
470
|
+
toState: "available",
|
|
471
|
+
qty,
|
|
472
|
+
reasonCode: options.reasonCode ?? "receipt",
|
|
473
|
+
sourceType: options.sourceType,
|
|
474
|
+
sourceId: options.sourceId,
|
|
475
|
+
note: options.note
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Move `qty` from `available` to `allocated` at the given location.
|
|
481
|
+
* Throws {@link InsufficientStockError} if available stock would go
|
|
482
|
+
* negative.
|
|
483
|
+
*/
|
|
484
|
+
async reserve(skuId, locationId, qty, options = {}) {
|
|
485
|
+
assertPositiveQty(qty, "reserve");
|
|
486
|
+
await this.runAtomically(async (tx) => {
|
|
487
|
+
await transitionState(tx, {
|
|
488
|
+
skuId,
|
|
489
|
+
locationId,
|
|
490
|
+
fromState: "available",
|
|
491
|
+
toState: "allocated",
|
|
492
|
+
qty,
|
|
493
|
+
reasonCode: options.reasonCode ?? "reservation",
|
|
494
|
+
sourceType: options.sourceType,
|
|
495
|
+
sourceId: options.sourceId,
|
|
496
|
+
note: options.note
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Move `qty` from `allocated` back to `available`. Used when a
|
|
502
|
+
* reservation is cancelled and the previously-reserved stock should
|
|
503
|
+
* go back into the available pool.
|
|
504
|
+
*/
|
|
505
|
+
async release(skuId, locationId, qty, options = {}) {
|
|
506
|
+
assertPositiveQty(qty, "release");
|
|
507
|
+
await this.runAtomically(async (tx) => {
|
|
508
|
+
await transitionState(tx, {
|
|
509
|
+
skuId,
|
|
510
|
+
locationId,
|
|
511
|
+
fromState: "allocated",
|
|
512
|
+
toState: "available",
|
|
513
|
+
qty,
|
|
514
|
+
reasonCode: options.reasonCode ?? "release",
|
|
515
|
+
sourceType: options.sourceType,
|
|
516
|
+
sourceId: options.sourceId,
|
|
517
|
+
note: options.note
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Remove `qty` from `allocated` at the given location. Stock leaves
|
|
523
|
+
* the building entirely (shipped, picked up, consumed). Throws
|
|
524
|
+
* {@link InsufficientStockError} if allocated stock would go negative.
|
|
525
|
+
*/
|
|
526
|
+
async fulfill(skuId, locationId, qty, options = {}) {
|
|
527
|
+
assertPositiveQty(qty, "fulfill");
|
|
528
|
+
await this.runAtomically(async (tx) => {
|
|
529
|
+
await assertAvailable(tx.levels, skuId, locationId, "allocated", qty);
|
|
530
|
+
await adjustLevel(tx.levels, {
|
|
531
|
+
skuId,
|
|
532
|
+
locationId,
|
|
533
|
+
state: "allocated",
|
|
534
|
+
delta: -qty,
|
|
535
|
+
// Already enforced via assertAvailable; skipping the second check
|
|
536
|
+
// avoids a needless extra DB round-trip in the hot path.
|
|
537
|
+
enforceNonNegative: false
|
|
538
|
+
});
|
|
539
|
+
await writeMovement(tx.movements, {
|
|
540
|
+
skuId,
|
|
541
|
+
locationId,
|
|
542
|
+
fromState: "allocated",
|
|
543
|
+
toState: null,
|
|
544
|
+
qty,
|
|
545
|
+
reasonCode: options.reasonCode ?? "fulfillment",
|
|
546
|
+
sourceType: options.sourceType,
|
|
547
|
+
sourceId: options.sourceId,
|
|
548
|
+
note: options.note
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Move `qty` of `available` stock from `fromLocationId` to
|
|
554
|
+
* `toLocationId`. Writes two movement rows — one for the `transfer_out`
|
|
555
|
+
* leg, one for the `transfer_in` leg — so the audit log preserves the
|
|
556
|
+
* lineage in both directions. Throws {@link InsufficientStockError} if
|
|
557
|
+
* source available stock would go negative.
|
|
558
|
+
*
|
|
559
|
+
* Both legs (level writes + movement rows) run inside one transaction
|
|
560
|
+
* — a failure mid-`transfer` rolls back the source debit so there's no
|
|
561
|
+
* "ghost stock disappearance" (source decremented, destination never
|
|
562
|
+
* credited).
|
|
563
|
+
*/
|
|
564
|
+
async transfer(skuId, fromLocationId, toLocationId, qty, options = {}) {
|
|
565
|
+
assertPositiveQty(qty, "transfer");
|
|
566
|
+
if (fromLocationId === toLocationId) {
|
|
567
|
+
throw new Error(
|
|
568
|
+
`transfer: fromLocationId and toLocationId must differ (got ${fromLocationId})`
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
await this.runAtomically(async (tx) => {
|
|
572
|
+
await assertAvailable(tx.levels, skuId, fromLocationId, "available", qty);
|
|
573
|
+
await adjustLevel(tx.levels, {
|
|
574
|
+
skuId,
|
|
575
|
+
locationId: fromLocationId,
|
|
576
|
+
state: "available",
|
|
577
|
+
delta: -qty,
|
|
578
|
+
enforceNonNegative: false
|
|
579
|
+
});
|
|
580
|
+
await writeMovement(tx.movements, {
|
|
581
|
+
skuId,
|
|
582
|
+
locationId: fromLocationId,
|
|
583
|
+
fromState: "available",
|
|
584
|
+
toState: null,
|
|
585
|
+
qty,
|
|
586
|
+
reasonCode: options.reasonCode ?? "transfer_out",
|
|
587
|
+
sourceType: options.sourceType,
|
|
588
|
+
sourceId: options.sourceId,
|
|
589
|
+
note: options.note
|
|
590
|
+
});
|
|
591
|
+
await adjustLevel(tx.levels, {
|
|
592
|
+
skuId,
|
|
593
|
+
locationId: toLocationId,
|
|
594
|
+
state: "available",
|
|
595
|
+
delta: qty
|
|
596
|
+
});
|
|
597
|
+
await writeMovement(tx.movements, {
|
|
598
|
+
skuId,
|
|
599
|
+
locationId: toLocationId,
|
|
600
|
+
fromState: null,
|
|
601
|
+
toState: "available",
|
|
602
|
+
qty,
|
|
603
|
+
reasonCode: options.reasonCode ?? "transfer_in",
|
|
604
|
+
sourceType: options.sourceType,
|
|
605
|
+
sourceId: options.sourceId,
|
|
606
|
+
note: options.note
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Apply a positive or negative `delta` to a level row. Used for cycle
|
|
612
|
+
* counts and one-off corrections; `delta=+5` adds five units,
|
|
613
|
+
* `delta=-2` removes two. By default the adjustment targets
|
|
614
|
+
* `available` stock; pass an explicit `state` to adjust a different
|
|
615
|
+
* bucket (e.g. `'damaged'` after a quality-control reclassification).
|
|
616
|
+
*
|
|
617
|
+
* Adjusting by `0` is rejected as a probable programming error — the
|
|
618
|
+
* caller almost always meant a non-zero delta and a no-op write would
|
|
619
|
+
* still cost an audit row.
|
|
620
|
+
*/
|
|
621
|
+
async adjust(skuId, locationId, delta, options = {}) {
|
|
622
|
+
if (!Number.isFinite(delta) || delta === 0) {
|
|
623
|
+
throw new Error(
|
|
624
|
+
`adjust: delta must be a non-zero finite number (got ${delta})`
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
const state = options.state ?? "available";
|
|
628
|
+
await this.runAtomically(async (tx) => {
|
|
629
|
+
if (delta < 0) {
|
|
630
|
+
await assertAvailable(tx.levels, skuId, locationId, state, -delta);
|
|
631
|
+
}
|
|
632
|
+
await adjustLevel(tx.levels, {
|
|
633
|
+
skuId,
|
|
634
|
+
locationId,
|
|
635
|
+
state,
|
|
636
|
+
delta,
|
|
637
|
+
enforceNonNegative: false
|
|
638
|
+
});
|
|
639
|
+
await writeMovement(tx.movements, {
|
|
640
|
+
skuId,
|
|
641
|
+
locationId,
|
|
642
|
+
fromState: delta < 0 ? state : null,
|
|
643
|
+
toState: delta > 0 ? state : null,
|
|
644
|
+
qty: Math.abs(delta),
|
|
645
|
+
reasonCode: options.reasonCode ?? "adjustment",
|
|
646
|
+
sourceType: options.sourceType,
|
|
647
|
+
sourceId: options.sourceId,
|
|
648
|
+
note: options.note
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async function assertAvailable(levels, skuId, locationId, state, requested) {
|
|
654
|
+
const level = await levels.getLevel(skuId, locationId, state);
|
|
655
|
+
const available = level ? Number(level.qty ?? 0) : 0;
|
|
656
|
+
if (available < requested) {
|
|
657
|
+
throw new InsufficientStockError(
|
|
658
|
+
skuId,
|
|
659
|
+
locationId,
|
|
660
|
+
state,
|
|
661
|
+
requested,
|
|
662
|
+
available
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
async function transitionState(tx, args) {
|
|
667
|
+
await assertAvailable(
|
|
668
|
+
tx.levels,
|
|
669
|
+
args.skuId,
|
|
670
|
+
args.locationId,
|
|
671
|
+
args.fromState,
|
|
672
|
+
args.qty
|
|
673
|
+
);
|
|
674
|
+
await adjustLevel(tx.levels, {
|
|
675
|
+
skuId: args.skuId,
|
|
676
|
+
locationId: args.locationId,
|
|
677
|
+
state: args.fromState,
|
|
678
|
+
delta: -args.qty,
|
|
679
|
+
enforceNonNegative: false
|
|
680
|
+
});
|
|
681
|
+
await adjustLevel(tx.levels, {
|
|
682
|
+
skuId: args.skuId,
|
|
683
|
+
locationId: args.locationId,
|
|
684
|
+
state: args.toState,
|
|
685
|
+
delta: args.qty
|
|
686
|
+
});
|
|
687
|
+
await writeMovement(tx.movements, {
|
|
688
|
+
skuId: args.skuId,
|
|
689
|
+
locationId: args.locationId,
|
|
690
|
+
fromState: args.fromState,
|
|
691
|
+
toState: args.toState,
|
|
692
|
+
qty: args.qty,
|
|
693
|
+
reasonCode: args.reasonCode,
|
|
694
|
+
sourceType: args.sourceType,
|
|
695
|
+
sourceId: args.sourceId,
|
|
696
|
+
note: args.note
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
async function adjustLevel(levels, options) {
|
|
700
|
+
const enforce = options.enforceNonNegative ?? options.delta < 0;
|
|
701
|
+
const existing = await levels.getLevel(
|
|
702
|
+
options.skuId,
|
|
703
|
+
options.locationId,
|
|
704
|
+
options.state
|
|
705
|
+
);
|
|
706
|
+
const previous = existing ? Number(existing.qty ?? 0) : 0;
|
|
707
|
+
const next = previous + options.delta;
|
|
708
|
+
if (enforce && next < 0) {
|
|
709
|
+
throw new InsufficientStockError(
|
|
710
|
+
options.skuId,
|
|
711
|
+
options.locationId,
|
|
712
|
+
options.state,
|
|
713
|
+
Math.abs(options.delta),
|
|
714
|
+
previous
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
if (existing) {
|
|
718
|
+
existing.qty = next;
|
|
719
|
+
await existing.save();
|
|
720
|
+
return existing;
|
|
721
|
+
}
|
|
722
|
+
const level = await levels.create({
|
|
723
|
+
skuId: options.skuId,
|
|
724
|
+
locationId: options.locationId,
|
|
725
|
+
state: options.state,
|
|
726
|
+
qty: next
|
|
727
|
+
});
|
|
728
|
+
return level;
|
|
729
|
+
}
|
|
730
|
+
async function writeMovement(movements, options) {
|
|
731
|
+
await movements.create({
|
|
732
|
+
skuId: options.skuId,
|
|
733
|
+
locationId: options.locationId,
|
|
734
|
+
fromState: options.fromState,
|
|
735
|
+
toState: options.toState,
|
|
736
|
+
qty: options.qty,
|
|
737
|
+
reasonCode: options.reasonCode,
|
|
738
|
+
sourceType: options.sourceType ?? "",
|
|
739
|
+
sourceId: options.sourceId ?? "",
|
|
740
|
+
note: options.note ?? "",
|
|
741
|
+
occurredAt: /* @__PURE__ */ new Date()
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
function assertPositiveQty(qty, op) {
|
|
745
|
+
if (!Number.isFinite(qty) || qty <= 0) {
|
|
746
|
+
throw new Error(`${op}: qty must be a positive finite number (got ${qty})`);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
let warnedNonTransactional = false;
|
|
750
|
+
function warnNonTransactional() {
|
|
751
|
+
if (warnedNonTransactional) return;
|
|
752
|
+
warnedNonTransactional = true;
|
|
753
|
+
logger$1.warn(
|
|
754
|
+
"[@happyvertical/smrt-inventory] StockService: underlying SQL adapter does not expose `transaction()`. Stock mutations are degrading to non-atomic serial writes — partial failures may leave the materialized level and the audit ledger out of sync. Upgrade @happyvertical/sql to >= 0.74.0 or use one of its built-in adapters."
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
async function createStockService(options) {
|
|
758
|
+
return StockService.create(options);
|
|
759
|
+
}
|
|
760
|
+
const logger = createLogger({ level: "info" });
|
|
761
|
+
async function installInventoryDispatchHandlers(options) {
|
|
762
|
+
const {
|
|
763
|
+
dispatchBus,
|
|
764
|
+
installContractReserved = true,
|
|
765
|
+
installFulfillmentShipped = true
|
|
766
|
+
} = options;
|
|
767
|
+
const stockService = options.stockService ?? await buildStockService(options);
|
|
768
|
+
const installed = [];
|
|
769
|
+
if (installContractReserved) {
|
|
770
|
+
const handler = async (payload, metadata) => {
|
|
771
|
+
await handleContractCreated(
|
|
772
|
+
stockService,
|
|
773
|
+
payload,
|
|
774
|
+
metadata
|
|
775
|
+
);
|
|
776
|
+
};
|
|
777
|
+
dispatchBus.on("contract:created", handler);
|
|
778
|
+
installed.push({ pattern: "contract:created", handler });
|
|
779
|
+
}
|
|
780
|
+
if (installFulfillmentShipped) {
|
|
781
|
+
const handler = async (payload, metadata) => {
|
|
782
|
+
await handleFulfillmentShipped(
|
|
783
|
+
stockService,
|
|
784
|
+
payload,
|
|
785
|
+
metadata
|
|
786
|
+
);
|
|
787
|
+
};
|
|
788
|
+
dispatchBus.on("fulfillment:shipped", handler);
|
|
789
|
+
installed.push({ pattern: "fulfillment:shipped", handler });
|
|
790
|
+
}
|
|
791
|
+
return {
|
|
792
|
+
stockService,
|
|
793
|
+
dispose() {
|
|
794
|
+
for (const entry of installed) {
|
|
795
|
+
dispatchBus.off(entry.pattern, entry.handler);
|
|
796
|
+
}
|
|
797
|
+
installed.length = 0;
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
async function handleContractCreated(stockService, payload, metadata) {
|
|
802
|
+
const reason = malformedContractPayloadReason(payload);
|
|
803
|
+
if (reason || !payload) {
|
|
804
|
+
warnMalformedPayload(
|
|
805
|
+
"contract:created",
|
|
806
|
+
payload,
|
|
807
|
+
metadata,
|
|
808
|
+
reason ?? "payload is null/undefined"
|
|
809
|
+
);
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (!areLinesWellFormed(payload.lines)) {
|
|
813
|
+
warnMalformedPayload(
|
|
814
|
+
"contract:created",
|
|
815
|
+
payload,
|
|
816
|
+
metadata,
|
|
817
|
+
"one or more entries in `lines` are null/undefined or missing required fields (skuId: string, locationId: string, qty: finite number)"
|
|
818
|
+
);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
const baseOptions = {
|
|
822
|
+
sourceType: "Contract",
|
|
823
|
+
sourceId: payload.contractId,
|
|
824
|
+
note: metadata.source ? `auto-reserve via ${metadata.source}` : void 0
|
|
825
|
+
};
|
|
826
|
+
await stockService.withTransaction(async (tx) => {
|
|
827
|
+
for (const line of payload.lines) {
|
|
828
|
+
await tx.reserve(line.skuId, line.locationId, line.qty, baseOptions);
|
|
829
|
+
}
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
async function handleFulfillmentShipped(stockService, payload, metadata) {
|
|
833
|
+
const reason = malformedFulfillmentPayloadReason(payload);
|
|
834
|
+
if (reason || !payload) {
|
|
835
|
+
warnMalformedPayload(
|
|
836
|
+
"fulfillment:shipped",
|
|
837
|
+
payload,
|
|
838
|
+
metadata,
|
|
839
|
+
reason ?? "payload is null/undefined"
|
|
840
|
+
);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (!areLinesWellFormed(payload.lines)) {
|
|
844
|
+
warnMalformedPayload(
|
|
845
|
+
"fulfillment:shipped",
|
|
846
|
+
payload,
|
|
847
|
+
metadata,
|
|
848
|
+
"one or more entries in `lines` are null/undefined or missing required fields (skuId: string, locationId: string, qty: finite number)"
|
|
849
|
+
);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const baseOptions = {
|
|
853
|
+
sourceType: "Fulfillment",
|
|
854
|
+
sourceId: payload.fulfillmentId,
|
|
855
|
+
note: metadata.source ? `auto-fulfill via ${metadata.source}` : void 0
|
|
856
|
+
};
|
|
857
|
+
await stockService.withTransaction(async (tx) => {
|
|
858
|
+
for (const line of payload.lines) {
|
|
859
|
+
await tx.fulfill(line.skuId, line.locationId, line.qty, baseOptions);
|
|
860
|
+
}
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
function malformedContractPayloadReason(payload) {
|
|
864
|
+
if (!payload || typeof payload !== "object") {
|
|
865
|
+
return "payload is null/undefined or not an object";
|
|
866
|
+
}
|
|
867
|
+
if (!payload.contractId || typeof payload.contractId !== "string") {
|
|
868
|
+
return "missing or non-string `contractId` (required for audit-trail source attribution)";
|
|
869
|
+
}
|
|
870
|
+
if (!Array.isArray(payload.lines)) {
|
|
871
|
+
return "missing or non-array `lines`";
|
|
872
|
+
}
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
function malformedFulfillmentPayloadReason(payload) {
|
|
876
|
+
if (!payload || typeof payload !== "object") {
|
|
877
|
+
return "payload is null/undefined or not an object";
|
|
878
|
+
}
|
|
879
|
+
if (!payload.fulfillmentId || typeof payload.fulfillmentId !== "string") {
|
|
880
|
+
return "missing or non-string `fulfillmentId` (required for audit-trail source attribution)";
|
|
881
|
+
}
|
|
882
|
+
if (!Array.isArray(payload.lines)) {
|
|
883
|
+
return "missing or non-array `lines`";
|
|
884
|
+
}
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
function areLinesWellFormed(lines) {
|
|
888
|
+
for (const line of lines) {
|
|
889
|
+
if (!line) return false;
|
|
890
|
+
if (typeof line.skuId !== "string" || !line.skuId) return false;
|
|
891
|
+
if (typeof line.locationId !== "string" || !line.locationId) return false;
|
|
892
|
+
if (typeof line.qty !== "number" || !Number.isFinite(line.qty))
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
895
|
+
return true;
|
|
896
|
+
}
|
|
897
|
+
function warnMalformedPayload(signal, payload, metadata, reason) {
|
|
898
|
+
logger.warn(
|
|
899
|
+
`[@happyvertical/smrt-inventory] 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}`
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
async function buildStockService(options) {
|
|
903
|
+
if (!options.db) {
|
|
904
|
+
throw new Error(
|
|
905
|
+
"installInventoryDispatchHandlers: either `stockService` or `db` is required"
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
return createStockService({ db: options.db });
|
|
909
|
+
}
|
|
910
|
+
export {
|
|
911
|
+
InsufficientStockError,
|
|
912
|
+
InventoryLocation,
|
|
913
|
+
InventoryLocationCollection,
|
|
914
|
+
StockLevel,
|
|
915
|
+
StockLevelCollection,
|
|
916
|
+
StockMovement,
|
|
917
|
+
StockMovementCollection,
|
|
918
|
+
StockService,
|
|
919
|
+
createStockService,
|
|
920
|
+
installInventoryDispatchHandlers
|
|
921
|
+
};
|
|
922
|
+
//# sourceMappingURL=index.js.map
|