@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 +743 -876
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +2 -2
- package/dist/smrt-knowledge.json +7 -7
- package/dist/types.js +0 -2
- package/package.json +9 -9
- package/dist/types.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,922 +1,789 @@
|
|
|
1
|
-
import { ObjectRegistry,
|
|
2
|
-
import {
|
|
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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
], StockLevel.prototype, "
|
|
124
|
-
__decorateClass$1([
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
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
|
-
|
|
676
|
+
var warnedNonTransactional = false;
|
|
750
677
|
function warnNonTransactional() {
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
683
|
+
return StockService.create(options);
|
|
759
684
|
}
|
|
760
|
-
|
|
685
|
+
//#endregion
|
|
686
|
+
//#region src/services/dispatch-handlers.ts
|
|
687
|
+
var logger = createLogger({ level: "info" });
|
|
761
688
|
async function installInventoryDispatchHandlers(options) {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
-
|
|
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
|
-
|
|
904
|
-
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|