@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.
@@ -0,0 +1,557 @@
1
+ import { DatabaseConfig } from '@happyvertical/smrt-core';
2
+ import { DispatchBus } from '@happyvertical/smrt-core';
3
+ import { SmrtCollection } from '@happyvertical/smrt-core';
4
+ import { SmrtObject } from '@happyvertical/smrt-core';
5
+ import { SmrtObjectOptions } from '@happyvertical/smrt-core';
6
+
7
+ /**
8
+ * Shape of a single reserved line in a `contract:created` payload. The
9
+ * producer (typically `@happyvertical/smrt-commerce`) packs one entry
10
+ * per line that needs stock motion; everything else (taxes, fees,
11
+ * non-physical items) should be filtered upstream.
12
+ */
13
+ export declare interface ContractCreatedLine {
14
+ skuId: string;
15
+ locationId: string;
16
+ qty: number;
17
+ }
18
+
19
+ /**
20
+ * Shape of the `contract:created` payload this handler expects. Carries
21
+ * the contract id (used for source attribution on the audit row) and
22
+ * the list of line items to reserve.
23
+ */
24
+ export declare interface ContractCreatedPayload {
25
+ contractId: string;
26
+ lines: ContractCreatedLine[];
27
+ }
28
+
29
+ /**
30
+ * Convenience factory. Returns a fully-initialized {@link StockService}
31
+ * sharing the given database with its internal collections.
32
+ */
33
+ export declare function createStockService(options: StockServiceOptions): Promise<StockService>;
34
+
35
+ /**
36
+ * Shape of a single shipped line in a `fulfillment:shipped` payload.
37
+ */
38
+ export declare interface FulfillmentShippedLine {
39
+ skuId: string;
40
+ locationId: string;
41
+ qty: number;
42
+ }
43
+
44
+ /**
45
+ * Shape of the `fulfillment:shipped` payload this handler expects.
46
+ * Producers emit one per shipped fulfilment; the handler fulfils each
47
+ * line against the level created by the matching `contract:created`
48
+ * reservation.
49
+ */
50
+ export declare interface FulfillmentShippedPayload {
51
+ fulfillmentId: string;
52
+ lines: FulfillmentShippedLine[];
53
+ }
54
+
55
+ /**
56
+ * Result handle returned by {@link installInventoryDispatchHandlers}.
57
+ * Call `dispose()` to detach the subscribers (mainly useful in tests
58
+ * and on graceful shutdown).
59
+ */
60
+ export declare interface InstalledInventoryDispatchHandlers {
61
+ /** Resolved StockService — useful for follow-up writes in the same scope. */
62
+ stockService: StockService;
63
+ /** Detach every installed subscriber. Idempotent. */
64
+ dispose(): void;
65
+ }
66
+
67
+ /**
68
+ * Subscribe a {@link StockService}-driven handler to the relevant signals
69
+ * on the given {@link DispatchBus}. Returns a disposer for tests and
70
+ * shutdown hooks.
71
+ *
72
+ * @example
73
+ * ```typescript
74
+ * import { createDispatchBus } from '@happyvertical/smrt-core';
75
+ * import { installInventoryDispatchHandlers } from '@happyvertical/smrt-inventory';
76
+ *
77
+ * const bus = await createDispatchBus({ db });
78
+ * const handlers = await installInventoryDispatchHandlers({
79
+ * dispatchBus: bus,
80
+ * db,
81
+ * });
82
+ * ```
83
+ */
84
+ export declare function installInventoryDispatchHandlers(options: InstallInventoryDispatchHandlersOptions): Promise<InstalledInventoryDispatchHandlers>;
85
+
86
+ /**
87
+ * Options accepted by {@link installInventoryDispatchHandlers}.
88
+ *
89
+ * Provide either a pre-built {@link StockService} (when sharing one
90
+ * across multiple subsystems) or a `db` for the helper to construct one
91
+ * on first use.
92
+ */
93
+ export declare type InstallInventoryDispatchHandlersOptions = {
94
+ /**
95
+ * Bus to subscribe on. Typically the app-wide `DispatchBus` created in
96
+ * the application's `smrt.ts`.
97
+ */
98
+ dispatchBus: DispatchBus;
99
+ /**
100
+ * When `true` (default), install the `contract:created` handler.
101
+ * Producers should publish a {@link ContractCreatedPayload}.
102
+ */
103
+ installContractReserved?: boolean;
104
+ /**
105
+ * When `true` (default), install the `fulfillment:shipped` handler.
106
+ * Producers should publish a {@link FulfillmentShippedPayload}.
107
+ */
108
+ installFulfillmentShipped?: boolean;
109
+ } & ({
110
+ stockService: StockService;
111
+ db?: DatabaseConfig;
112
+ } | {
113
+ db: DatabaseConfig;
114
+ stockService?: undefined;
115
+ });
116
+
117
+ /**
118
+ * Thrown by {@link StockService.reserve} (and {@link StockService.fulfill},
119
+ * {@link StockService.transfer}) when the caller asks to move more stock
120
+ * than the source state currently holds. Carries enough context for a
121
+ * caller to surface a meaningful UI message and decide whether to retry,
122
+ * backorder, or cancel.
123
+ */
124
+ export declare class InsufficientStockError extends Error {
125
+ readonly skuId: string;
126
+ readonly locationId: string;
127
+ readonly state: StockState;
128
+ readonly requested: number;
129
+ readonly available: number;
130
+ name: string;
131
+ constructor(skuId: string, locationId: string, state: StockState, requested: number, available: number);
132
+ }
133
+
134
+ export declare class InventoryLocation extends SmrtObject {
135
+ /** Tenant scope. `null` means the location record is global. */
136
+ tenantId: string | null;
137
+ /**
138
+ * Short stable identifier (`'WH-EAST'`, `'STORE-42'`, `'IN-TRANSIT'`).
139
+ * Together with `tenantId` this is the natural key.
140
+ */
141
+ code: string;
142
+ /** Display name for UIs. */
143
+ name: string;
144
+ /**
145
+ * Open-ended classifier (`'warehouse'`, `'factory'`, `'retail'`,
146
+ * `'in_transit'`, `'virtual'`, or anything else your domain needs).
147
+ * The framework never branches on this value.
148
+ */
149
+ kind: InventoryLocationKind;
150
+ /**
151
+ * Optional plain-string reference to a `Place.id` in
152
+ * `@happyvertical/smrt-places`. Cross-package id; intentionally not a
153
+ * `@foreignKey()` so this package can be used without `smrt-places`
154
+ * installed.
155
+ */
156
+ placeId: string;
157
+ /** Soft-active flag — inactive locations stay queryable for history. */
158
+ active: boolean;
159
+ constructor(options?: InventoryLocationOptions);
160
+ }
161
+
162
+ export declare class InventoryLocationCollection extends SmrtCollection<InventoryLocation> {
163
+ static readonly _itemClass: typeof InventoryLocation;
164
+ /**
165
+ * Look up a location by its tenant-scoped `code`. Returns `null` when
166
+ * no row matches.
167
+ */
168
+ findByCode(code: string): Promise<InventoryLocation | null>;
169
+ /**
170
+ * Find every location classified as the given kind (`'warehouse'`,
171
+ * `'factory'`, `'retail'`, `'in_transit'`, …).
172
+ */
173
+ findByKind(kind: InventoryLocationKind): Promise<InventoryLocation[]>;
174
+ /**
175
+ * Find every location linked to a particular `Place.id` from
176
+ * `@happyvertical/smrt-places`. Returns an empty array when no row
177
+ * references the place.
178
+ */
179
+ findByPlace(placeId: string): Promise<InventoryLocation[]>;
180
+ /** Find every active location, optionally narrowed by kind. */
181
+ findActive(kind?: InventoryLocationKind): Promise<InventoryLocation[]>;
182
+ }
183
+
184
+ /**
185
+ * Open-ended classifier for an InventoryLocation. The framework does not
186
+ * special-case any value — pass whatever taxonomy your application needs.
187
+ *
188
+ * The strings listed here are conventions, not an exhaustive enum:
189
+ * - `warehouse` — fulfillment warehouse
190
+ * - `factory` — manufacturing site
191
+ * - `retail` — storefront / point-of-sale
192
+ * - `in_transit` — virtual location for stock that has left A but not yet
193
+ * arrived at B; balances `transfer()` semantics
194
+ * - `virtual` — any other non-physical bucket (returns staging, scrap,
195
+ * consignment pool)
196
+ */
197
+ export declare type InventoryLocationKind = string;
198
+
199
+ /**
200
+ * Options accepted by the {@link InventoryLocation} constructor.
201
+ */
202
+ export declare interface InventoryLocationOptions extends SmrtObjectOptions {
203
+ tenantId?: string | null;
204
+ code?: string;
205
+ name?: string;
206
+ kind?: InventoryLocationKind;
207
+ placeId?: string;
208
+ active?: boolean;
209
+ }
210
+
211
+ export declare class StockLevel extends SmrtObject {
212
+ /** Tenant scope. `null` means the level row is global. */
213
+ tenantId: string | null;
214
+ /** Plain string reference to the {@link Sku} this row tracks. */
215
+ skuId: string;
216
+ /** Plain string reference to the {@link InventoryLocation} this row tracks. */
217
+ locationId: string;
218
+ /** Logical state — `available`, `allocated`, `wip`, `qc_hold`, `damaged`. */
219
+ state: StockState;
220
+ /**
221
+ * Current quantity. Fractional values are allowed (`= 0.0`) for
222
+ * domains that count in units of measure other than whole pieces
223
+ * (kilograms, litres, metres).
224
+ */
225
+ qty: number;
226
+ constructor(options?: StockLevelOptions);
227
+ }
228
+
229
+ export declare class StockLevelCollection extends SmrtCollection<StockLevel> {
230
+ static readonly _itemClass: typeof StockLevel;
231
+ /**
232
+ * Fetch the level row for a `(skuId, locationId, state)` tuple, or
233
+ * `null` when the row has never been written. State defaults to
234
+ * `'available'` because that is the common case (selling / picking
235
+ * decisions are driven by available stock).
236
+ */
237
+ getLevel(skuId: string, locationId: string, state?: StockState): Promise<StockLevel | null>;
238
+ /**
239
+ * Return every level row for the given SKU across all locations and
240
+ * states. Useful for "where is this SKU?" admin screens.
241
+ */
242
+ findBySku(skuId: string): Promise<StockLevel[]>;
243
+ /**
244
+ * Return every level row at the given location. Useful for "what is
245
+ * in this warehouse?" reports.
246
+ */
247
+ findByLocation(locationId: string): Promise<StockLevel[]>;
248
+ /**
249
+ * Sum `qty` across all level rows for the given SKU. Pass `state` to
250
+ * narrow the sum to one logical state (e.g. only `available`);
251
+ * omit it for grand total across all states.
252
+ */
253
+ totalForSku(skuId: string, state?: StockState): Promise<number>;
254
+ /**
255
+ * Sum `qty` across all level rows at the given location, optionally
256
+ * narrowed by state.
257
+ */
258
+ totalForLocation(locationId: string, state?: StockState): Promise<number>;
259
+ }
260
+
261
+ /**
262
+ * Options accepted by the {@link StockLevel} constructor.
263
+ */
264
+ export declare interface StockLevelOptions extends SmrtObjectOptions {
265
+ tenantId?: string | null;
266
+ skuId?: string;
267
+ locationId?: string;
268
+ state?: StockState;
269
+ qty?: number;
270
+ }
271
+
272
+ export declare class StockMovement extends SmrtObject {
273
+ /** Tenant scope. `null` means the movement is global. */
274
+ tenantId: string | null;
275
+ /** Plain string reference to the {@link Sku} being moved. */
276
+ skuId: string;
277
+ /** Plain string reference to the {@link InventoryLocation} being mutated. */
278
+ locationId: string;
279
+ /**
280
+ * Origin state for transitions (e.g. `available` → `allocated` for a
281
+ * reservation). `null` indicates "no origin" — used when stock enters
282
+ * the system fresh via {@link StockService.receive} or production.
283
+ */
284
+ fromState: StockState | null;
285
+ /**
286
+ * Destination state. `null` indicates "no destination" — used for
287
+ * fulfilment, where stock leaves the building entirely.
288
+ */
289
+ toState: StockState | null;
290
+ /** Quantity moved. Always positive; the direction is encoded by from/to. */
291
+ qty: number;
292
+ /**
293
+ * Why the movement happened (see {@link StockMovementReason}). Drawn
294
+ * from the canonical list when emitted by {@link StockService}; free-form
295
+ * strings are allowed for vertical-specific reasons.
296
+ */
297
+ reasonCode: StockMovementReason;
298
+ /**
299
+ * Cross-package attribution tag — e.g. `'Contract'`, `'Fulfillment'`,
300
+ * `'ProductionOrder'`, `'CycleCount'`. The package writing the
301
+ * movement decides what tag makes sense; readers can group by
302
+ * `(sourceType, sourceId)` to reconstruct "what caused this".
303
+ */
304
+ sourceType: string;
305
+ /**
306
+ * Cross-package id of the row that caused this movement. Plain string;
307
+ * the framework never dereferences it.
308
+ */
309
+ sourceId: string;
310
+ /** Optional free-form note shown in audit UIs. */
311
+ note: string;
312
+ /**
313
+ * When the movement happened. Set to `now` at write time; explicit
314
+ * values are allowed when back-dating an import.
315
+ */
316
+ occurredAt: Date;
317
+ constructor(options?: StockMovementOptions);
318
+ }
319
+
320
+ export declare class StockMovementCollection extends SmrtCollection<StockMovement> {
321
+ static readonly _itemClass: typeof StockMovement;
322
+ /**
323
+ * Return every movement for the given SKU, newest first. Useful for a
324
+ * per-SKU audit trail.
325
+ */
326
+ findBySku(skuId: string): Promise<StockMovement[]>;
327
+ /**
328
+ * Return every movement at the given location, newest first. Useful
329
+ * for a per-warehouse audit trail.
330
+ */
331
+ findByLocation(locationId: string): Promise<StockMovement[]>;
332
+ /**
333
+ * Return every movement attributed to the given upstream source — for
334
+ * example `findBySource('Contract', contract.id)` returns every
335
+ * movement caused by the reservation/fulfilment/release of that
336
+ * contract. Newest first.
337
+ */
338
+ findBySource(sourceType: string, sourceId: string): Promise<StockMovement[]>;
339
+ /**
340
+ * Return every movement with the given reason code (`'receipt'`,
341
+ * `'reservation'`, `'adjustment'`, …). Newest first.
342
+ */
343
+ findByReason(reasonCode: StockMovementReason): Promise<StockMovement[]>;
344
+ }
345
+
346
+ /**
347
+ * Options accepted by the {@link StockMovement} constructor.
348
+ */
349
+ export declare interface StockMovementOptions extends SmrtObjectOptions {
350
+ tenantId?: string | null;
351
+ skuId?: string;
352
+ locationId?: string;
353
+ fromState?: StockState | null;
354
+ toState?: StockState | null;
355
+ qty?: number;
356
+ reasonCode?: StockMovementReason;
357
+ sourceType?: string;
358
+ sourceId?: string;
359
+ note?: string;
360
+ occurredAt?: Date | string;
361
+ }
362
+
363
+ /**
364
+ * Why a StockMovement was written.
365
+ *
366
+ * Each {@link StockService} method writes one movement with a `reasonCode`
367
+ * drawn from this list. Free-form `string` is also accepted so consumers
368
+ * can introduce vocabulary specific to their business (e.g. `'shrink'`,
369
+ * `'sample'`, `'consignment_out'`) without forking the package.
370
+ */
371
+ export declare type StockMovementReason = 'receipt' | 'reservation' | 'release' | 'fulfillment' | 'transfer_out' | 'transfer_in' | 'adjustment' | 'production_consume' | 'production_produce' | (string & {});
372
+
373
+ /**
374
+ * Options shared by every {@link StockService} method that wants to leave
375
+ * an audit attribution behind. Pairs neatly with the cross-package
376
+ * pattern in {@link StockMovement.sourceType} / {@link StockMovement.sourceId}.
377
+ */
378
+ export declare interface StockMutationOptions {
379
+ /** Cross-package tag, e.g. `'Contract'`, `'Fulfillment'`, `'CycleCount'`. */
380
+ sourceType?: string;
381
+ /** Cross-package id of the row that caused this mutation. */
382
+ sourceId?: string;
383
+ /** Free-form note shown in audit UIs. */
384
+ note?: string;
385
+ /**
386
+ * Override the reason code stamped on the {@link StockMovement}. Each
387
+ * method picks a sensible default; explicit overrides are useful when a
388
+ * vertical wants to flag a more specific reason (e.g. `'return'`
389
+ * instead of `'receipt'`).
390
+ */
391
+ reasonCode?: StockMovementReason;
392
+ }
393
+
394
+ /**
395
+ * Sanctioned stock-mutation surface.
396
+ *
397
+ * Construct via {@link createStockService} — the static factory wires up
398
+ * the underlying collections and shares one database connection across
399
+ * level reads and movement writes.
400
+ *
401
+ * @example
402
+ * ```typescript
403
+ * const service = await createStockService({ db });
404
+ * await service.receive(sku.id, warehouse.id, 100, {
405
+ * sourceType: 'PurchaseOrder',
406
+ * sourceId: po.id,
407
+ * });
408
+ * await service.reserve(sku.id, warehouse.id, 10, {
409
+ * sourceType: 'Contract',
410
+ * sourceId: order.id,
411
+ * });
412
+ * await service.fulfill(sku.id, warehouse.id, 10, {
413
+ * sourceType: 'Fulfillment',
414
+ * sourceId: fulfillment.id,
415
+ * });
416
+ * ```
417
+ */
418
+ export declare class StockService {
419
+ /**
420
+ * The database config this service was bound to (URL string, config
421
+ * object, or already-resolved `DatabaseInterface`). Exposed so
422
+ * downstream services that compose StockService (e.g. BomService,
423
+ * ProductionService in `@happyvertical/smrt-manufacturing`) can pass
424
+ * the same value to their own collection factories without reaching
425
+ * into private fields on the collections.
426
+ */
427
+ readonly db: DatabaseConfig;
428
+ readonly levels: StockLevelCollection;
429
+ readonly movements: StockMovementCollection;
430
+ readonly locations: InventoryLocationCollection;
431
+ /**
432
+ * Marks a service instance handed to a {@link withTransaction}
433
+ * callback. Public mutation methods on a tx-bound instance skip
434
+ * opening a nested transaction and just execute against the already-
435
+ * bound collections; outer (non-tx) instances open a fresh
436
+ * transaction per mutation. Internal flag — consumers never set it.
437
+ */
438
+ private readonly inTransaction;
439
+ private constructor();
440
+ /** Internal factory — prefer {@link createStockService}. */
441
+ static create(options: StockServiceOptions): Promise<StockService>;
442
+ /**
443
+ * Run `work` inside a single database transaction with a tx-bound
444
+ * {@link StockService} instance. All mutation calls on `tx` commit
445
+ * atomically when `work` resolves and roll back if it throws.
446
+ *
447
+ * Use this when you need atomicity ACROSS multiple stock-service
448
+ * calls — e.g. consuming materials for every line of a production
449
+ * order in `@happyvertical/smrt-manufacturing`'s `ProductionService`,
450
+ * or a custom workflow that reserves + fulfills + writes a custom
451
+ * audit comment in one indivisible step. Individual mutation methods
452
+ * (`receive`, `reserve`, etc.) are already atomic on their own — you
453
+ * only need `withTransaction` for cross-call composition.
454
+ *
455
+ * Nesting is safe: calling `tx.withTransaction(...)` inside an
456
+ * already-tx-bound callback simply runs the inner `work` on the same
457
+ * transaction without opening a savepoint.
458
+ *
459
+ * When the underlying adapter does not expose `transaction()`, falls
460
+ * through to a serial run on the regular collections with a one-time
461
+ * warning. All four built-in adapters in `@happyvertical/sql >= 0.74.0`
462
+ * support it; only test stubs would hit this branch.
463
+ */
464
+ withTransaction<T>(work: (tx: StockService) => Promise<T>): Promise<T>;
465
+ /**
466
+ * Internal: run a single-method mutation in a transaction. If we're
467
+ * already inside one (the instance was handed to a `withTransaction`
468
+ * callback), reuse it; otherwise open a fresh one.
469
+ */
470
+ private runAtomically;
471
+ /**
472
+ * Add `qty` to available stock at the given location. Used for
473
+ * purchase-order receipts, customer returns going back into available
474
+ * inventory, and the "produce" leg of a production order.
475
+ */
476
+ receive(skuId: string, locationId: string, qty: number, options?: StockMutationOptions): Promise<void>;
477
+ /**
478
+ * Move `qty` from `available` to `allocated` at the given location.
479
+ * Throws {@link InsufficientStockError} if available stock would go
480
+ * negative.
481
+ */
482
+ reserve(skuId: string, locationId: string, qty: number, options?: StockMutationOptions): Promise<void>;
483
+ /**
484
+ * Move `qty` from `allocated` back to `available`. Used when a
485
+ * reservation is cancelled and the previously-reserved stock should
486
+ * go back into the available pool.
487
+ */
488
+ release(skuId: string, locationId: string, qty: number, options?: StockMutationOptions): Promise<void>;
489
+ /**
490
+ * Remove `qty` from `allocated` at the given location. Stock leaves
491
+ * the building entirely (shipped, picked up, consumed). Throws
492
+ * {@link InsufficientStockError} if allocated stock would go negative.
493
+ */
494
+ fulfill(skuId: string, locationId: string, qty: number, options?: StockMutationOptions): Promise<void>;
495
+ /**
496
+ * Move `qty` of `available` stock from `fromLocationId` to
497
+ * `toLocationId`. Writes two movement rows — one for the `transfer_out`
498
+ * leg, one for the `transfer_in` leg — so the audit log preserves the
499
+ * lineage in both directions. Throws {@link InsufficientStockError} if
500
+ * source available stock would go negative.
501
+ *
502
+ * Both legs (level writes + movement rows) run inside one transaction
503
+ * — a failure mid-`transfer` rolls back the source debit so there's no
504
+ * "ghost stock disappearance" (source decremented, destination never
505
+ * credited).
506
+ */
507
+ transfer(skuId: string, fromLocationId: string, toLocationId: string, qty: number, options?: StockMutationOptions): Promise<void>;
508
+ /**
509
+ * Apply a positive or negative `delta` to a level row. Used for cycle
510
+ * counts and one-off corrections; `delta=+5` adds five units,
511
+ * `delta=-2` removes two. By default the adjustment targets
512
+ * `available` stock; pass an explicit `state` to adjust a different
513
+ * bucket (e.g. `'damaged'` after a quality-control reclassification).
514
+ *
515
+ * Adjusting by `0` is rejected as a probable programming error — the
516
+ * caller almost always meant a non-zero delta and a no-op write would
517
+ * still cost an audit row.
518
+ */
519
+ adjust(skuId: string, locationId: string, delta: number, options?: StockMutationOptions & {
520
+ state?: StockState;
521
+ }): Promise<void>;
522
+ }
523
+
524
+ /**
525
+ * Options accepted by the {@link StockService} factory.
526
+ */
527
+ export declare interface StockServiceOptions {
528
+ /**
529
+ * Database to read/write through. Accepts the same shapes that
530
+ * `SmrtCollection.create({ db })` accepts — a `DatabaseInterface`, a
531
+ * connection-string URL, or a `{ type, url }` config object. Reused by
532
+ * the internal collections so the service, level reads, and movement
533
+ * writes always hit the same connection / pool.
534
+ */
535
+ db: DatabaseConfig;
536
+ }
537
+
538
+ /**
539
+ * Logical state of a quantity of stock at a `(skuId, locationId)` pair.
540
+ *
541
+ * StockLevel rows are tuples of `(skuId, locationId, state)`, so a single
542
+ * SKU at a single location can simultaneously have non-zero quantities in
543
+ * several states (e.g. 50 available, 10 allocated, 3 damaged).
544
+ *
545
+ * - `available` — on hand and free to allocate.
546
+ * - `allocated` — reserved against a contract, order, or production plan;
547
+ * physically still on site but no longer free to sell.
548
+ * - `wip` — work-in-progress: consumed materials inside an active
549
+ * production order, not yet emitted as finished goods.
550
+ * - `qc_hold` — held pending quality control; not available to allocate
551
+ * or ship until released.
552
+ * - `damaged` — damaged or otherwise unsellable; kept on the books for
553
+ * shrinkage accounting until written off.
554
+ */
555
+ export declare type StockState = 'available' | 'allocated' | 'wip' | 'qc_hold' | 'damaged';
556
+
557
+ export { }