@blamejs/blamejs-shop 0.0.56 → 0.0.57
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/CHANGELOG.md +2 -0
- package/lib/backorder.js +452 -0
- package/lib/bundles.js +587 -0
- package/lib/fraud-screen.js +808 -0
- package/lib/index.js +10 -0
- package/lib/inventory-locations.js +774 -0
- package/lib/order-export.js +724 -0
- package/lib/order-notes.js +563 -0
- package/lib/payment-methods.js +522 -0
- package/lib/print-on-demand.js +709 -0
- package/lib/save-for-later.js +667 -0
- package/lib/variants.js +726 -0
- package/package.json +1 -1
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.inventoryLocations
|
|
4
|
+
* @title Inventory locations — multi-warehouse stock + order routing
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* The catalog inventory surface holds a single `stock_on_hand`
|
|
8
|
+
* per SKU — enough until the operator runs from more than one
|
|
9
|
+
* physical bucket. Multi-location operators (a warehouse plus a
|
|
10
|
+
* retail counter, a regional warehouse plus a dropship supplier,
|
|
11
|
+
* a network of stores each picking from their own shelf) need
|
|
12
|
+
* per-location stock counts AND a routing layer that picks which
|
|
13
|
+
* location ships any given order.
|
|
14
|
+
*
|
|
15
|
+
* This primitive is the location-aware counterpart of the
|
|
16
|
+
* catalog's single-bucket inventory. It owns three tables:
|
|
17
|
+
*
|
|
18
|
+
* inventory_locations — operator-defined places (warehouse,
|
|
19
|
+
* retail, dropship). `code` is the
|
|
20
|
+
* operator-chosen short id (`WH-EAST`,
|
|
21
|
+
* `STORE-NYC`); priority controls
|
|
22
|
+
* routing order.
|
|
23
|
+
* inventory_stock — per (sku, location_code) quantity.
|
|
24
|
+
* The composite PK gives the upsert
|
|
25
|
+
* path a single INSERT ... ON CONFLICT.
|
|
26
|
+
* inventory_adjustments — append-only audit log: every
|
|
27
|
+
* setStock / adjustStock /
|
|
28
|
+
* transferStock writes a row.
|
|
29
|
+
*
|
|
30
|
+
* Verbs:
|
|
31
|
+
* defineLocation — register a location (refuses
|
|
32
|
+
* duplicate code, invalid type, etc).
|
|
33
|
+
* listLocations / getLocation / updateLocation / deactivateLocation
|
|
34
|
+
* — operator CRUD over the location set.
|
|
35
|
+
* setStock — absolute set (overwrite).
|
|
36
|
+
* adjustStock — relative delta with audit row.
|
|
37
|
+
* transferStock — atomic two-row write moving qty
|
|
38
|
+
* from one location to another; if
|
|
39
|
+
* the source doesn't have enough, the
|
|
40
|
+
* whole transfer refuses (no money
|
|
41
|
+
* created, no row half-committed).
|
|
42
|
+
* stockForSku — `{ total, by_location: [...] }`.
|
|
43
|
+
* totalForSku — sum across every location.
|
|
44
|
+
* availableLocations — locations with quantity > 0.
|
|
45
|
+
* routeOrder — given `{ lines: [{ sku, qty }] }`,
|
|
46
|
+
* returns `{ allocation, unfulfillable }`
|
|
47
|
+
* under one of three strategies:
|
|
48
|
+
* 'priority-fill' (default)
|
|
49
|
+
* 'single-location-cheapest'
|
|
50
|
+
* 'nearest-by-postal-prefix'
|
|
51
|
+
*
|
|
52
|
+
* Composition:
|
|
53
|
+
* - b.uuid.v7 — adjustment row PKs (sortable by insertion)
|
|
54
|
+
* - catalog — held for future cross-checks (current
|
|
55
|
+
* version doesn't read from it; the
|
|
56
|
+
* factory still requires it so the boot
|
|
57
|
+
* wiring fails loud when a caller forgets
|
|
58
|
+
* to provide one)
|
|
59
|
+
*
|
|
60
|
+
* Three-tier input validation (use the discipline; don't write
|
|
61
|
+
* the labels): every public verb on this module is a defensive
|
|
62
|
+
* request-shape reader OR a config-time entry point. Both throw
|
|
63
|
+
* on bad input. No drop-silent hot-path sinks here.
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
var bShop;
|
|
67
|
+
function _b() {
|
|
68
|
+
if (!bShop) bShop = require("./index");
|
|
69
|
+
return bShop.framework;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---- constants ----------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
75
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
76
|
+
var NAME_RE = /^[\S\s]{1,128}$/;
|
|
77
|
+
var MAX_REASON = 256;
|
|
78
|
+
var MAX_LINES = 1000;
|
|
79
|
+
var TYPES = Object.freeze(["warehouse", "retail", "dropship"]);
|
|
80
|
+
var STRATEGIES = Object.freeze([
|
|
81
|
+
"priority-fill",
|
|
82
|
+
"single-location-cheapest",
|
|
83
|
+
"nearest-by-postal-prefix",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
// ---- validators ---------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
function _code(s, label) {
|
|
89
|
+
if (typeof s !== "string" || !CODE_RE.test(s)) {
|
|
90
|
+
throw new TypeError("inventory-locations: " + (label || "code") +
|
|
91
|
+
" must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function _sku(s) {
|
|
95
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
96
|
+
throw new TypeError("inventory-locations: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function _name(s) {
|
|
100
|
+
if (typeof s !== "string" || !NAME_RE.test(s) || s.length > 128) {
|
|
101
|
+
throw new TypeError("inventory-locations: name must be a non-empty string ≤ 128 chars");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function _type(s) {
|
|
105
|
+
if (typeof s !== "string" || TYPES.indexOf(s) === -1) {
|
|
106
|
+
throw new TypeError("inventory-locations: type must be one of " + TYPES.join(", ") +
|
|
107
|
+
", got " + JSON.stringify(s));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function _priority(n) {
|
|
111
|
+
if (!Number.isInteger(n) || n < 0 || n > 1000000) {
|
|
112
|
+
throw new TypeError("inventory-locations: priority must be a non-negative integer ≤ 1000000");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function _nonNegInt(n, label) {
|
|
116
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
117
|
+
throw new TypeError("inventory-locations: " + label + " must be a non-negative integer");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function _int(n, label) {
|
|
121
|
+
if (!Number.isInteger(n)) {
|
|
122
|
+
throw new TypeError("inventory-locations: " + label + " must be an integer");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function _positiveInt(n, label) {
|
|
126
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
127
|
+
throw new TypeError("inventory-locations: " + label + " must be a positive integer");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function _reason(s) {
|
|
131
|
+
if (s == null) return "";
|
|
132
|
+
if (typeof s !== "string" || s.length > MAX_REASON) {
|
|
133
|
+
throw new TypeError("inventory-locations: reason must be a string ≤ " + MAX_REASON + " chars");
|
|
134
|
+
}
|
|
135
|
+
return s;
|
|
136
|
+
}
|
|
137
|
+
function _addressJson(v) {
|
|
138
|
+
if (v == null) return null;
|
|
139
|
+
// Either pre-serialized JSON string or an object the primitive
|
|
140
|
+
// serializes for the operator. Refuse anything else — the
|
|
141
|
+
// address has to round-trip through JSON cleanly.
|
|
142
|
+
if (typeof v === "string") {
|
|
143
|
+
try { JSON.parse(v); }
|
|
144
|
+
catch (_e) { throw new TypeError("inventory-locations: address must be a JSON-parseable string or a plain object"); }
|
|
145
|
+
if (v.length > 4000) {
|
|
146
|
+
throw new TypeError("inventory-locations: address JSON must be ≤ 4000 chars");
|
|
147
|
+
}
|
|
148
|
+
return v;
|
|
149
|
+
}
|
|
150
|
+
if (typeof v === "object") {
|
|
151
|
+
var s;
|
|
152
|
+
try { s = JSON.stringify(v); }
|
|
153
|
+
catch (_e2) { throw new TypeError("inventory-locations: address object must serialize via JSON.stringify"); }
|
|
154
|
+
if (s.length > 4000) {
|
|
155
|
+
throw new TypeError("inventory-locations: address JSON must be ≤ 4000 chars");
|
|
156
|
+
}
|
|
157
|
+
return s;
|
|
158
|
+
}
|
|
159
|
+
throw new TypeError("inventory-locations: address must be a JSON-parseable string or a plain object");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _now() { return Date.now(); }
|
|
163
|
+
|
|
164
|
+
function _parseAddress(raw) {
|
|
165
|
+
if (raw == null) return null;
|
|
166
|
+
try { return JSON.parse(raw); }
|
|
167
|
+
catch (_e) { return null; }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---- factory ------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
function create(opts) {
|
|
173
|
+
opts = opts || {};
|
|
174
|
+
// The catalog handle is held so a future revision can cross-
|
|
175
|
+
// reference SKUs against catalog.products.exists(sku) etc — the
|
|
176
|
+
// current version doesn't read from it, but the factory still
|
|
177
|
+
// requires it so the boot wiring fails loud when a caller
|
|
178
|
+
// forgets to thread the catalog through.
|
|
179
|
+
if (!opts.catalog || typeof opts.catalog !== "object") {
|
|
180
|
+
throw new TypeError("inventory-locations.create: opts.catalog is required");
|
|
181
|
+
}
|
|
182
|
+
var query = opts.query;
|
|
183
|
+
if (!query) {
|
|
184
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Hydrate a row, parsing the JSON address + coercing the
|
|
188
|
+
// INTEGER active flag to a real boolean. Returns null on miss
|
|
189
|
+
// so the caller can map cleanly to HTTP 404.
|
|
190
|
+
function _shapeLocation(row) {
|
|
191
|
+
if (!row) return null;
|
|
192
|
+
return {
|
|
193
|
+
code: row.code,
|
|
194
|
+
name: row.name,
|
|
195
|
+
type: row.type,
|
|
196
|
+
address: _parseAddress(row.address_json),
|
|
197
|
+
priority: row.priority,
|
|
198
|
+
active: row.active === 1 || row.active === true,
|
|
199
|
+
created_at: row.created_at,
|
|
200
|
+
updated_at: row.updated_at,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Append an audit row. Called inline from every stock-mutating
|
|
205
|
+
// verb. Throws if the underlying insert fails — callers don't
|
|
206
|
+
// catch this; the mutation should never be considered "applied"
|
|
207
|
+
// without the audit trail.
|
|
208
|
+
async function _audit(sku, locationCode, delta, reason) {
|
|
209
|
+
await query(
|
|
210
|
+
"INSERT INTO inventory_adjustments (id, sku, location_code, delta, reason, occurred_at) " +
|
|
211
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
212
|
+
[_b().uuid.v7(), sku, locationCode, delta, reason || "", _now()],
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function _getLocationRow(code) {
|
|
217
|
+
var r = await query("SELECT * FROM inventory_locations WHERE code = ?1", [code]);
|
|
218
|
+
return r.rows[0] || null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function _getStockRow(sku, code) {
|
|
222
|
+
var r = await query(
|
|
223
|
+
"SELECT * FROM inventory_stock WHERE sku = ?1 AND location_code = ?2",
|
|
224
|
+
[sku, code],
|
|
225
|
+
);
|
|
226
|
+
return r.rows[0] || null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Pull the active-locations list sorted by (priority ASC, code
|
|
230
|
+
// ASC). The deterministic tie-break by code matters: two
|
|
231
|
+
// locations at the same priority should always pick in the
|
|
232
|
+
// same order across calls so routing is reproducible.
|
|
233
|
+
async function _activeLocationsSorted() {
|
|
234
|
+
var r = await query(
|
|
235
|
+
"SELECT * FROM inventory_locations WHERE active = 1 ORDER BY priority ASC, code ASC",
|
|
236
|
+
[],
|
|
237
|
+
);
|
|
238
|
+
return r.rows.map(_shapeLocation);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---- routing strategies ----------------------------------------------
|
|
242
|
+
|
|
243
|
+
// priority-fill: walks active locations in (priority, code)
|
|
244
|
+
// order. For each line, pulls from each location greedily until
|
|
245
|
+
// the line's qty is satisfied. Unfilled qty lands on
|
|
246
|
+
// `unfulfillable`. Lines that span multiple locations are split
|
|
247
|
+
// across them — the result groups allocations by location_code
|
|
248
|
+
// so downstream packers see "this box ships from WH-EAST,
|
|
249
|
+
// contains these lines."
|
|
250
|
+
async function _routePriorityFill(lines) {
|
|
251
|
+
var locations = await _activeLocationsSorted();
|
|
252
|
+
var byLoc = {}; // code -> { location_code, lines: [{sku, quantity}] }
|
|
253
|
+
var unfulfillable = []; // [{ sku, quantity }]
|
|
254
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
255
|
+
var line = lines[i];
|
|
256
|
+
var remaining = line.quantity;
|
|
257
|
+
for (var j = 0; j < locations.length && remaining > 0; j += 1) {
|
|
258
|
+
var loc = locations[j];
|
|
259
|
+
var stock = await _getStockRow(line.sku, loc.code);
|
|
260
|
+
if (!stock || stock.quantity <= 0) continue;
|
|
261
|
+
var pick = Math.min(stock.quantity, remaining);
|
|
262
|
+
if (!byLoc[loc.code]) byLoc[loc.code] = { location_code: loc.code, lines: [] };
|
|
263
|
+
byLoc[loc.code].lines.push({ sku: line.sku, quantity: pick });
|
|
264
|
+
remaining -= pick;
|
|
265
|
+
}
|
|
266
|
+
if (remaining > 0) unfulfillable.push({ sku: line.sku, quantity: remaining });
|
|
267
|
+
}
|
|
268
|
+
// Emit allocations in priority order, not insertion order, so
|
|
269
|
+
// the caller sees the highest-priority shipper first.
|
|
270
|
+
var allocation = [];
|
|
271
|
+
for (var k = 0; k < locations.length; k += 1) {
|
|
272
|
+
var c = locations[k].code;
|
|
273
|
+
if (byLoc[c]) allocation.push(byLoc[c]);
|
|
274
|
+
}
|
|
275
|
+
return { allocation: allocation, unfulfillable: unfulfillable };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// single-location-cheapest: pick the single active location with
|
|
279
|
+
// the lowest priority that can fill EVERY line completely. If no
|
|
280
|
+
// single location can cover the whole order, every line lands
|
|
281
|
+
// on unfulfillable (the strategy by name refuses splits — the
|
|
282
|
+
// caller picks `priority-fill` if they want partial allocation).
|
|
283
|
+
async function _routeSingleLocationCheapest(lines) {
|
|
284
|
+
var locations = await _activeLocationsSorted();
|
|
285
|
+
for (var j = 0; j < locations.length; j += 1) {
|
|
286
|
+
var loc = locations[j];
|
|
287
|
+
var ok = true;
|
|
288
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
289
|
+
var stock = await _getStockRow(lines[i].sku, loc.code);
|
|
290
|
+
if (!stock || stock.quantity < lines[i].quantity) { ok = false; break; }
|
|
291
|
+
}
|
|
292
|
+
if (ok) {
|
|
293
|
+
var allocLines = lines.map(function (l) { return { sku: l.sku, quantity: l.quantity }; });
|
|
294
|
+
return {
|
|
295
|
+
allocation: [{ location_code: loc.code, lines: allocLines }],
|
|
296
|
+
unfulfillable: [],
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// No single location can cover — push every line onto
|
|
301
|
+
// unfulfillable so the caller can either fall back to
|
|
302
|
+
// priority-fill or surface the gap to the operator.
|
|
303
|
+
return {
|
|
304
|
+
allocation: [],
|
|
305
|
+
unfulfillable: lines.map(function (l) { return { sku: l.sku, quantity: l.quantity }; }),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// nearest-by-postal-prefix: operator supplies `{ destinationPostal,
|
|
310
|
+
// postalMap }` where postalMap is `{ <location_code>: <postal-prefix-string> }`.
|
|
311
|
+
// The strategy scores each active location by the length of the
|
|
312
|
+
// shared prefix between destinationPostal and that location's
|
|
313
|
+
// entry; highest score wins, ties broken by priority then code.
|
|
314
|
+
// Once the winning order is decided, the strategy falls back to
|
|
315
|
+
// priority-fill semantics (greedy split across the ordered
|
|
316
|
+
// locations) so partial coverage doesn't drop the order.
|
|
317
|
+
async function _routeNearestByPostalPrefix(lines, strategyOpts) {
|
|
318
|
+
if (!strategyOpts || typeof strategyOpts !== "object") {
|
|
319
|
+
throw new TypeError("inventory-locations.routeOrder(nearest-by-postal-prefix): " +
|
|
320
|
+
"strategyOpts.destinationPostal + strategyOpts.postalMap required");
|
|
321
|
+
}
|
|
322
|
+
var dest = strategyOpts.destinationPostal;
|
|
323
|
+
if (typeof dest !== "string" || !dest.length || dest.length > 32) {
|
|
324
|
+
throw new TypeError("inventory-locations.routeOrder(nearest-by-postal-prefix): " +
|
|
325
|
+
"destinationPostal must be a non-empty string ≤ 32 chars");
|
|
326
|
+
}
|
|
327
|
+
var map = strategyOpts.postalMap;
|
|
328
|
+
if (!map || typeof map !== "object") {
|
|
329
|
+
throw new TypeError("inventory-locations.routeOrder(nearest-by-postal-prefix): " +
|
|
330
|
+
"postalMap must be an object of { location_code: postal-prefix }");
|
|
331
|
+
}
|
|
332
|
+
var locations = await _activeLocationsSorted();
|
|
333
|
+
function _sharedPrefix(a, b) {
|
|
334
|
+
if (typeof a !== "string" || typeof b !== "string") return 0;
|
|
335
|
+
var n = Math.min(a.length, b.length);
|
|
336
|
+
for (var i = 0; i < n; i += 1) {
|
|
337
|
+
if (a.charCodeAt(i) !== b.charCodeAt(i)) return i;
|
|
338
|
+
}
|
|
339
|
+
return n;
|
|
340
|
+
}
|
|
341
|
+
// Decorate locations with their score; sort by (score DESC,
|
|
342
|
+
// priority ASC, code ASC).
|
|
343
|
+
var scored = locations.map(function (loc) {
|
|
344
|
+
var prefix = map[loc.code];
|
|
345
|
+
var score = _sharedPrefix(dest, prefix || "");
|
|
346
|
+
return { loc: loc, score: score };
|
|
347
|
+
});
|
|
348
|
+
scored.sort(function (a, b) {
|
|
349
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
350
|
+
if (a.loc.priority !== b.loc.priority) return a.loc.priority - b.loc.priority;
|
|
351
|
+
if (a.loc.code < b.loc.code) return -1;
|
|
352
|
+
if (a.loc.code > b.loc.code) return 1;
|
|
353
|
+
return 0;
|
|
354
|
+
});
|
|
355
|
+
var ordered = scored.map(function (s) { return s.loc; });
|
|
356
|
+
var byLoc = {};
|
|
357
|
+
var unfulfillable = [];
|
|
358
|
+
for (var i2 = 0; i2 < lines.length; i2 += 1) {
|
|
359
|
+
var line = lines[i2];
|
|
360
|
+
var remaining = line.quantity;
|
|
361
|
+
for (var j2 = 0; j2 < ordered.length && remaining > 0; j2 += 1) {
|
|
362
|
+
var loc2 = ordered[j2];
|
|
363
|
+
var stock = await _getStockRow(line.sku, loc2.code);
|
|
364
|
+
if (!stock || stock.quantity <= 0) continue;
|
|
365
|
+
var pick = Math.min(stock.quantity, remaining);
|
|
366
|
+
if (!byLoc[loc2.code]) byLoc[loc2.code] = { location_code: loc2.code, lines: [] };
|
|
367
|
+
byLoc[loc2.code].lines.push({ sku: line.sku, quantity: pick });
|
|
368
|
+
remaining -= pick;
|
|
369
|
+
}
|
|
370
|
+
if (remaining > 0) unfulfillable.push({ sku: line.sku, quantity: remaining });
|
|
371
|
+
}
|
|
372
|
+
var allocation = [];
|
|
373
|
+
for (var k2 = 0; k2 < ordered.length; k2 += 1) {
|
|
374
|
+
var c2 = ordered[k2].code;
|
|
375
|
+
if (byLoc[c2]) allocation.push(byLoc[c2]);
|
|
376
|
+
}
|
|
377
|
+
return { allocation: allocation, unfulfillable: unfulfillable };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
|
|
382
|
+
// Register a location. Refuses a duplicate code up front with
|
|
383
|
+
// a descriptive message instead of relying on the PK
|
|
384
|
+
// constraint's raw SQLITE_CONSTRAINT throw. The address is
|
|
385
|
+
// serialized to JSON for storage; readers parse it back via
|
|
386
|
+
// the row-shaper.
|
|
387
|
+
defineLocation: async function (input) {
|
|
388
|
+
if (!input || typeof input !== "object") {
|
|
389
|
+
throw new TypeError("inventory-locations.defineLocation: input object required");
|
|
390
|
+
}
|
|
391
|
+
_code(input.code, "code");
|
|
392
|
+
_name(input.name);
|
|
393
|
+
_type(input.type);
|
|
394
|
+
var priority = input.priority == null ? 100 : input.priority;
|
|
395
|
+
_priority(priority);
|
|
396
|
+
var active = input.active == null ? true : input.active;
|
|
397
|
+
if (typeof active !== "boolean") {
|
|
398
|
+
throw new TypeError("inventory-locations.defineLocation: active must be a boolean");
|
|
399
|
+
}
|
|
400
|
+
var address = _addressJson(input.address);
|
|
401
|
+
|
|
402
|
+
var dup = await query(
|
|
403
|
+
"SELECT code FROM inventory_locations WHERE code = ?1 LIMIT 1",
|
|
404
|
+
[input.code],
|
|
405
|
+
);
|
|
406
|
+
if (dup.rows.length) {
|
|
407
|
+
throw new TypeError("inventory-locations.defineLocation: code " +
|
|
408
|
+
JSON.stringify(input.code) + " already exists");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
var ts = _now();
|
|
412
|
+
await query(
|
|
413
|
+
"INSERT INTO inventory_locations (code, name, type, address_json, priority, active, created_at, updated_at) " +
|
|
414
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)",
|
|
415
|
+
[input.code, input.name, input.type, address, priority, active ? 1 : 0, ts],
|
|
416
|
+
);
|
|
417
|
+
return _shapeLocation(await _getLocationRow(input.code));
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
// List locations. `active_only: true` filters out deactivated
|
|
421
|
+
// rows. Sort is (priority ASC, code ASC) — operators expect
|
|
422
|
+
// the routing order in the admin UI.
|
|
423
|
+
listLocations: async function (listOpts) {
|
|
424
|
+
listOpts = listOpts || {};
|
|
425
|
+
var sql, params;
|
|
426
|
+
if (listOpts.active_only) {
|
|
427
|
+
sql = "SELECT * FROM inventory_locations WHERE active = 1 ORDER BY priority ASC, code ASC";
|
|
428
|
+
params = [];
|
|
429
|
+
} else {
|
|
430
|
+
sql = "SELECT * FROM inventory_locations ORDER BY priority ASC, code ASC";
|
|
431
|
+
params = [];
|
|
432
|
+
}
|
|
433
|
+
var rows = (await query(sql, params)).rows;
|
|
434
|
+
return rows.map(_shapeLocation);
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
// Read one location. Returns null on miss so the caller-
|
|
438
|
+
// handler maps cleanly to HTTP 404.
|
|
439
|
+
getLocation: async function (code) {
|
|
440
|
+
_code(code, "code");
|
|
441
|
+
return _shapeLocation(await _getLocationRow(code));
|
|
442
|
+
},
|
|
443
|
+
|
|
444
|
+
// Patch a subset of mutable fields. `code` and `created_at`
|
|
445
|
+
// are immutable; every other field is patchable. Refuses an
|
|
446
|
+
// unknown code with a descriptive error.
|
|
447
|
+
updateLocation: async function (code, patch) {
|
|
448
|
+
_code(code, "code");
|
|
449
|
+
if (!patch || typeof patch !== "object") {
|
|
450
|
+
throw new TypeError("inventory-locations.updateLocation: patch object required");
|
|
451
|
+
}
|
|
452
|
+
var existing = await _getLocationRow(code);
|
|
453
|
+
if (!existing) {
|
|
454
|
+
throw new TypeError("inventory-locations.updateLocation: code " +
|
|
455
|
+
JSON.stringify(code) + " not found");
|
|
456
|
+
}
|
|
457
|
+
var sets = [];
|
|
458
|
+
var params = [];
|
|
459
|
+
var idx = 1;
|
|
460
|
+
if (Object.prototype.hasOwnProperty.call(patch, "name")) {
|
|
461
|
+
_name(patch.name);
|
|
462
|
+
sets.push("name = ?" + idx); params.push(patch.name); idx += 1;
|
|
463
|
+
}
|
|
464
|
+
if (Object.prototype.hasOwnProperty.call(patch, "type")) {
|
|
465
|
+
_type(patch.type);
|
|
466
|
+
sets.push("type = ?" + idx); params.push(patch.type); idx += 1;
|
|
467
|
+
}
|
|
468
|
+
if (Object.prototype.hasOwnProperty.call(patch, "priority")) {
|
|
469
|
+
_priority(patch.priority);
|
|
470
|
+
sets.push("priority = ?" + idx); params.push(patch.priority); idx += 1;
|
|
471
|
+
}
|
|
472
|
+
if (Object.prototype.hasOwnProperty.call(patch, "address")) {
|
|
473
|
+
var addr = _addressJson(patch.address);
|
|
474
|
+
sets.push("address_json = ?" + idx); params.push(addr); idx += 1;
|
|
475
|
+
}
|
|
476
|
+
if (Object.prototype.hasOwnProperty.call(patch, "active")) {
|
|
477
|
+
if (typeof patch.active !== "boolean") {
|
|
478
|
+
throw new TypeError("inventory-locations.updateLocation: active must be a boolean");
|
|
479
|
+
}
|
|
480
|
+
sets.push("active = ?" + idx); params.push(patch.active ? 1 : 0); idx += 1;
|
|
481
|
+
}
|
|
482
|
+
if (sets.length === 0) {
|
|
483
|
+
// No-op patch — still return the current row so the
|
|
484
|
+
// admin UI can refresh.
|
|
485
|
+
return _shapeLocation(existing);
|
|
486
|
+
}
|
|
487
|
+
sets.push("updated_at = ?" + idx); params.push(_now()); idx += 1;
|
|
488
|
+
params.push(code);
|
|
489
|
+
await query(
|
|
490
|
+
"UPDATE inventory_locations SET " + sets.join(", ") + " WHERE code = ?" + idx,
|
|
491
|
+
params,
|
|
492
|
+
);
|
|
493
|
+
return _shapeLocation(await _getLocationRow(code));
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
// Sugar over `updateLocation({ active: false })`. The row
|
|
497
|
+
// stays in the table so historical adjustments still resolve
|
|
498
|
+
// their location_code reference.
|
|
499
|
+
deactivateLocation: async function (code) {
|
|
500
|
+
_code(code, "code");
|
|
501
|
+
var existing = await _getLocationRow(code);
|
|
502
|
+
if (!existing) {
|
|
503
|
+
throw new TypeError("inventory-locations.deactivateLocation: code " +
|
|
504
|
+
JSON.stringify(code) + " not found");
|
|
505
|
+
}
|
|
506
|
+
await query(
|
|
507
|
+
"UPDATE inventory_locations SET active = 0, updated_at = ?1 WHERE code = ?2",
|
|
508
|
+
[_now(), code],
|
|
509
|
+
);
|
|
510
|
+
return _shapeLocation(await _getLocationRow(code));
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
// Absolute set: overwrites the (sku, location_code) row to
|
|
514
|
+
// exactly `quantity`. Audit row captures the signed delta vs
|
|
515
|
+
// the prior value so the audit log reflects the actual change.
|
|
516
|
+
setStock: async function (input) {
|
|
517
|
+
if (!input || typeof input !== "object") {
|
|
518
|
+
throw new TypeError("inventory-locations.setStock: input object required");
|
|
519
|
+
}
|
|
520
|
+
_sku(input.sku);
|
|
521
|
+
_code(input.location_code, "location_code");
|
|
522
|
+
_nonNegInt(input.quantity, "quantity");
|
|
523
|
+
var loc = await _getLocationRow(input.location_code);
|
|
524
|
+
if (!loc) {
|
|
525
|
+
throw new TypeError("inventory-locations.setStock: location_code " +
|
|
526
|
+
JSON.stringify(input.location_code) + " not found");
|
|
527
|
+
}
|
|
528
|
+
var prev = await _getStockRow(input.sku, input.location_code);
|
|
529
|
+
var prevQty = prev ? prev.quantity : 0;
|
|
530
|
+
var ts = _now();
|
|
531
|
+
if (prev) {
|
|
532
|
+
await query(
|
|
533
|
+
"UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
|
|
534
|
+
[input.quantity, ts, input.sku, input.location_code],
|
|
535
|
+
);
|
|
536
|
+
} else {
|
|
537
|
+
await query(
|
|
538
|
+
"INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
|
539
|
+
[input.sku, input.location_code, input.quantity, ts],
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
var delta = input.quantity - prevQty;
|
|
543
|
+
await _audit(input.sku, input.location_code, delta, _reason(input.reason));
|
|
544
|
+
return { sku: input.sku, location_code: input.location_code, quantity: input.quantity, delta: delta };
|
|
545
|
+
},
|
|
546
|
+
|
|
547
|
+
// Relative delta with audit row. The composite quantity must
|
|
548
|
+
// remain non-negative — a negative delta that would drive the
|
|
549
|
+
// row below zero refuses the whole operation. Inserts a row
|
|
550
|
+
// at quantity 0 + delta if the (sku, location_code) pair has
|
|
551
|
+
// never been touched before (and delta is positive).
|
|
552
|
+
adjustStock: async function (input) {
|
|
553
|
+
if (!input || typeof input !== "object") {
|
|
554
|
+
throw new TypeError("inventory-locations.adjustStock: input object required");
|
|
555
|
+
}
|
|
556
|
+
_sku(input.sku);
|
|
557
|
+
_code(input.location_code, "location_code");
|
|
558
|
+
_int(input.delta, "delta");
|
|
559
|
+
if (input.delta === 0) {
|
|
560
|
+
throw new TypeError("inventory-locations.adjustStock: delta must be non-zero");
|
|
561
|
+
}
|
|
562
|
+
var loc = await _getLocationRow(input.location_code);
|
|
563
|
+
if (!loc) {
|
|
564
|
+
throw new TypeError("inventory-locations.adjustStock: location_code " +
|
|
565
|
+
JSON.stringify(input.location_code) + " not found");
|
|
566
|
+
}
|
|
567
|
+
var prev = await _getStockRow(input.sku, input.location_code);
|
|
568
|
+
var prevQty = prev ? prev.quantity : 0;
|
|
569
|
+
var next = prevQty + input.delta;
|
|
570
|
+
if (next < 0) {
|
|
571
|
+
throw new TypeError("inventory-locations.adjustStock: delta " + input.delta +
|
|
572
|
+
" would drive stock below zero (current=" + prevQty + ", sku=" + input.sku +
|
|
573
|
+
", location=" + input.location_code + ")");
|
|
574
|
+
}
|
|
575
|
+
var ts = _now();
|
|
576
|
+
if (prev) {
|
|
577
|
+
await query(
|
|
578
|
+
"UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
|
|
579
|
+
[next, ts, input.sku, input.location_code],
|
|
580
|
+
);
|
|
581
|
+
} else {
|
|
582
|
+
await query(
|
|
583
|
+
"INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
|
584
|
+
[input.sku, input.location_code, next, ts],
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
await _audit(input.sku, input.location_code, input.delta, _reason(input.reason));
|
|
588
|
+
return { sku: input.sku, location_code: input.location_code, quantity: next, delta: input.delta };
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
// Atomic two-row move. Reads the source quantity; if
|
|
592
|
+
// insufficient, refuses the whole transfer before mutating
|
|
593
|
+
// anything (no money created, no row half-committed). Writes
|
|
594
|
+
// two audit rows (negative on source, positive on destination)
|
|
595
|
+
// so the audit log preserves the pairing.
|
|
596
|
+
transferStock: async function (input) {
|
|
597
|
+
if (!input || typeof input !== "object") {
|
|
598
|
+
throw new TypeError("inventory-locations.transferStock: input object required");
|
|
599
|
+
}
|
|
600
|
+
_sku(input.sku);
|
|
601
|
+
_code(input.from_location, "from_location");
|
|
602
|
+
_code(input.to_location, "to_location");
|
|
603
|
+
if (input.from_location === input.to_location) {
|
|
604
|
+
throw new TypeError("inventory-locations.transferStock: from_location and to_location must differ");
|
|
605
|
+
}
|
|
606
|
+
_positiveInt(input.quantity, "quantity");
|
|
607
|
+
|
|
608
|
+
var fromLoc = await _getLocationRow(input.from_location);
|
|
609
|
+
if (!fromLoc) {
|
|
610
|
+
throw new TypeError("inventory-locations.transferStock: from_location " +
|
|
611
|
+
JSON.stringify(input.from_location) + " not found");
|
|
612
|
+
}
|
|
613
|
+
var toLoc = await _getLocationRow(input.to_location);
|
|
614
|
+
if (!toLoc) {
|
|
615
|
+
throw new TypeError("inventory-locations.transferStock: to_location " +
|
|
616
|
+
JSON.stringify(input.to_location) + " not found");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
var fromRow = await _getStockRow(input.sku, input.from_location);
|
|
620
|
+
var fromQty = fromRow ? fromRow.quantity : 0;
|
|
621
|
+
if (fromQty < input.quantity) {
|
|
622
|
+
throw new TypeError("inventory-locations.transferStock: insufficient stock at " +
|
|
623
|
+
input.from_location + " (have " + fromQty + ", need " + input.quantity + ")");
|
|
624
|
+
}
|
|
625
|
+
var toRow = await _getStockRow(input.sku, input.to_location);
|
|
626
|
+
var toQty = toRow ? toRow.quantity : 0;
|
|
627
|
+
|
|
628
|
+
var ts = _now();
|
|
629
|
+
// Decrement source first. If the destination upsert below
|
|
630
|
+
// fails for any reason, the rollback path re-increments the
|
|
631
|
+
// source so the invariant (total quantity unchanged) holds.
|
|
632
|
+
await query(
|
|
633
|
+
"UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
|
|
634
|
+
[fromQty - input.quantity, ts, input.sku, input.from_location],
|
|
635
|
+
);
|
|
636
|
+
try {
|
|
637
|
+
if (toRow) {
|
|
638
|
+
await query(
|
|
639
|
+
"UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
|
|
640
|
+
[toQty + input.quantity, ts, input.sku, input.to_location],
|
|
641
|
+
);
|
|
642
|
+
} else {
|
|
643
|
+
await query(
|
|
644
|
+
"INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
|
645
|
+
[input.sku, input.to_location, input.quantity, ts],
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
} catch (e) {
|
|
649
|
+
// Best-effort compensating restore on the source so the
|
|
650
|
+
// pair stays balanced. If THIS write also fails the
|
|
651
|
+
// operator will see two adjacent audit rows with no
|
|
652
|
+
// counterpart — the caller can replay or reconcile.
|
|
653
|
+
try {
|
|
654
|
+
await query(
|
|
655
|
+
"UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
|
|
656
|
+
[fromQty, _now(), input.sku, input.from_location],
|
|
657
|
+
);
|
|
658
|
+
} catch (_e2) { /* drop-silent — the original error is what the caller needs to fix */ }
|
|
659
|
+
throw e;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
var reason = _reason(input.reason);
|
|
663
|
+
await _audit(input.sku, input.from_location, -input.quantity, reason);
|
|
664
|
+
await _audit(input.sku, input.to_location, input.quantity, reason);
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
sku: input.sku,
|
|
668
|
+
from_location: input.from_location,
|
|
669
|
+
to_location: input.to_location,
|
|
670
|
+
quantity: input.quantity,
|
|
671
|
+
from_after: fromQty - input.quantity,
|
|
672
|
+
to_after: toQty + input.quantity,
|
|
673
|
+
};
|
|
674
|
+
},
|
|
675
|
+
|
|
676
|
+
// Stock breakdown for a single SKU. by_location is ordered by
|
|
677
|
+
// (priority ASC, code ASC) — operators read it as the routing
|
|
678
|
+
// order. Locations with zero stock that have ever held the
|
|
679
|
+
// SKU are still included so the UI can show "out of stock at
|
|
680
|
+
// STORE-NYC" instead of silently dropping the row.
|
|
681
|
+
stockForSku: async function (sku) {
|
|
682
|
+
_sku(sku);
|
|
683
|
+
var r = await query(
|
|
684
|
+
"SELECT s.sku, s.location_code, s.quantity, l.priority " +
|
|
685
|
+
"FROM inventory_stock s " +
|
|
686
|
+
"JOIN inventory_locations l ON l.code = s.location_code " +
|
|
687
|
+
"WHERE s.sku = ?1 " +
|
|
688
|
+
"ORDER BY l.priority ASC, l.code ASC",
|
|
689
|
+
[sku],
|
|
690
|
+
);
|
|
691
|
+
var total = 0;
|
|
692
|
+
var byLocation = r.rows.map(function (row) {
|
|
693
|
+
total += row.quantity;
|
|
694
|
+
return { code: row.location_code, quantity: row.quantity };
|
|
695
|
+
});
|
|
696
|
+
return { total: total, by_location: byLocation };
|
|
697
|
+
},
|
|
698
|
+
|
|
699
|
+
// Sum across every location. Faster than stockForSku when the
|
|
700
|
+
// caller only needs the headline number.
|
|
701
|
+
totalForSku: async function (sku) {
|
|
702
|
+
_sku(sku);
|
|
703
|
+
var r = await query(
|
|
704
|
+
"SELECT COALESCE(SUM(quantity), 0) AS total FROM inventory_stock WHERE sku = ?1",
|
|
705
|
+
[sku],
|
|
706
|
+
);
|
|
707
|
+
return r.rows[0] ? Number(r.rows[0].total) : 0;
|
|
708
|
+
},
|
|
709
|
+
|
|
710
|
+
// Active locations with quantity > 0 for the SKU, ordered by
|
|
711
|
+
// (priority, code). Convenience for "where can we ship this
|
|
712
|
+
// SKU from?" admin views.
|
|
713
|
+
availableLocations: async function (sku) {
|
|
714
|
+
_sku(sku);
|
|
715
|
+
var r = await query(
|
|
716
|
+
"SELECT s.location_code, s.quantity, l.priority " +
|
|
717
|
+
"FROM inventory_stock s " +
|
|
718
|
+
"JOIN inventory_locations l ON l.code = s.location_code " +
|
|
719
|
+
"WHERE s.sku = ?1 AND s.quantity > 0 AND l.active = 1 " +
|
|
720
|
+
"ORDER BY l.priority ASC, l.code ASC",
|
|
721
|
+
[sku],
|
|
722
|
+
);
|
|
723
|
+
return r.rows.map(function (row) {
|
|
724
|
+
return { code: row.location_code, quantity: row.quantity };
|
|
725
|
+
});
|
|
726
|
+
},
|
|
727
|
+
|
|
728
|
+
// Route an order. Strategy is one of:
|
|
729
|
+
// 'priority-fill' (default — greedy split by priority)
|
|
730
|
+
// 'single-location-cheapest' (refuses splits)
|
|
731
|
+
// 'nearest-by-postal-prefix' (operator supplies postal map)
|
|
732
|
+
// Validates lines + dispatches.
|
|
733
|
+
routeOrder: async function (input) {
|
|
734
|
+
if (!input || typeof input !== "object") {
|
|
735
|
+
throw new TypeError("inventory-locations.routeOrder: input object required");
|
|
736
|
+
}
|
|
737
|
+
if (!Array.isArray(input.lines) || input.lines.length === 0) {
|
|
738
|
+
throw new TypeError("inventory-locations.routeOrder: lines must be a non-empty array");
|
|
739
|
+
}
|
|
740
|
+
if (input.lines.length > MAX_LINES) {
|
|
741
|
+
throw new TypeError("inventory-locations.routeOrder: lines must contain ≤ " + MAX_LINES + " entries");
|
|
742
|
+
}
|
|
743
|
+
var normalized = [];
|
|
744
|
+
for (var i = 0; i < input.lines.length; i += 1) {
|
|
745
|
+
var l = input.lines[i];
|
|
746
|
+
if (!l || typeof l !== "object") {
|
|
747
|
+
throw new TypeError("inventory-locations.routeOrder: lines[" + i + "] must be an object");
|
|
748
|
+
}
|
|
749
|
+
_sku(l.sku);
|
|
750
|
+
_positiveInt(l.quantity, "lines[" + i + "].quantity");
|
|
751
|
+
normalized.push({ sku: l.sku, quantity: l.quantity });
|
|
752
|
+
}
|
|
753
|
+
var strategy = input.strategy == null ? "priority-fill" : input.strategy;
|
|
754
|
+
if (STRATEGIES.indexOf(strategy) === -1) {
|
|
755
|
+
throw new TypeError("inventory-locations.routeOrder: strategy must be one of " +
|
|
756
|
+
STRATEGIES.join(", ") + ", got " + JSON.stringify(strategy));
|
|
757
|
+
}
|
|
758
|
+
if (strategy === "priority-fill") {
|
|
759
|
+
return await _routePriorityFill(normalized);
|
|
760
|
+
}
|
|
761
|
+
if (strategy === "single-location-cheapest") {
|
|
762
|
+
return await _routeSingleLocationCheapest(normalized);
|
|
763
|
+
}
|
|
764
|
+
// nearest-by-postal-prefix
|
|
765
|
+
return await _routeNearestByPostalPrefix(normalized, input.strategyOpts);
|
|
766
|
+
},
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
module.exports = {
|
|
771
|
+
create: create,
|
|
772
|
+
TYPES: TYPES,
|
|
773
|
+
STRATEGIES: STRATEGIES,
|
|
774
|
+
};
|