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