@happyvertical/smrt-inventory 0.37.2 → 0.37.3

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