@blamejs/blamejs-shop 0.0.59 → 0.0.60
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/api-keys.js +789 -0
- package/lib/barcodes.js +671 -0
- package/lib/coupon-stacking.js +717 -0
- package/lib/customer-portal.js +359 -0
- package/lib/experiments.js +697 -0
- package/lib/index.js +14 -0
- package/lib/inventory-snapshots.js +691 -0
- package/lib/print-receipts.js +675 -0
- package/lib/product-import.js +1034 -0
- package/lib/storefront-pages.js +701 -0
- package/lib/subscription-billing.js +644 -0
- package/lib/tax-rates.js +559 -0
- package/lib/tenants.js +665 -0
- package/lib/translations.js +553 -0
- package/lib/webhook-subscriptions.js +565 -0
- package/package.json +1 -1
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.inventorySnapshots
|
|
4
|
+
* @title Inventory snapshots — point-in-time inventory captures for
|
|
5
|
+
* audit + reconciliation
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* The catalog `inventory` table answers "right now"; the per-
|
|
9
|
+
* location `inventory_stock` table (from inventory-locations)
|
|
10
|
+
* answers "right now, broken down by warehouse." Neither answers
|
|
11
|
+
* "what did stock look like at close of business yesterday?" or
|
|
12
|
+
* "how did the count change between the EOM snapshot and today?"
|
|
13
|
+
*
|
|
14
|
+
* This primitive captures a point-in-time copy of every (sku,
|
|
15
|
+
* location_code) -> quantity pair the operator cares about and
|
|
16
|
+
* pins it under an operator-chosen label. Subsequent snapshots
|
|
17
|
+
* are diffable: `deltaBetween(from, to)` walks both row sets and
|
|
18
|
+
* produces a per-key delta record showing additions / removals /
|
|
19
|
+
* net changes, plus an explicit no-change list when the caller
|
|
20
|
+
* wants to see what stayed flat.
|
|
21
|
+
*
|
|
22
|
+
* Verbs:
|
|
23
|
+
* takeSnapshot({ label, locations?, skus?, reason })
|
|
24
|
+
* — Captures the current stock counts. With `locations` and a
|
|
25
|
+
* wired `inventoryLocations` handle, reads from
|
|
26
|
+
* `inventory_stock` and persists per-(sku, location_code)
|
|
27
|
+
* rows; otherwise reads from the catalog `inventory` table
|
|
28
|
+
* and persists with `location_code = NULL` (the "no per-
|
|
29
|
+
* location detail" sentinel). `skus` narrows the capture to
|
|
30
|
+
* the listed SKUs only (otherwise every SKU with a row in
|
|
31
|
+
* the source table is captured). Returns
|
|
32
|
+
* `{ id, label, taken_at, sku_count, location_count,
|
|
33
|
+
* total_units, hash_sha3_512 }`.
|
|
34
|
+
*
|
|
35
|
+
* getSnapshot(snapshot_id)
|
|
36
|
+
* — Reads the snapshot header + hydrated rows. Returns null on
|
|
37
|
+
* miss so the caller-handler maps cleanly to HTTP 404.
|
|
38
|
+
*
|
|
39
|
+
* listSnapshots({ from?, to?, limit?, cursor? })
|
|
40
|
+
* — Paginated list ordered (taken_at DESC, id DESC). `from` /
|
|
41
|
+
* `to` are inclusive epoch-ms bounds. Cursor is HMAC-tagged
|
|
42
|
+
* so an operator can't hand-craft one to skip ahead.
|
|
43
|
+
*
|
|
44
|
+
* deltaBetween({ from_snapshot_id, to_snapshot_id, sku? })
|
|
45
|
+
* — Per-(sku, location_code) deltas. Each row is
|
|
46
|
+
* `{ sku, location_code, from_quantity, to_quantity, delta,
|
|
47
|
+
* change }` where `change` is one of:
|
|
48
|
+
* 'added' — present in `to`, not in `from`
|
|
49
|
+
* 'removed' — present in `from`, not in `to`
|
|
50
|
+
* 'changed' — present in both with different quantity
|
|
51
|
+
* 'unchanged' — present in both with equal quantity
|
|
52
|
+
* When the optional `sku` filter is supplied only deltas for
|
|
53
|
+
* that SKU are returned (across every location_code).
|
|
54
|
+
*
|
|
55
|
+
* summary(snapshot_id)
|
|
56
|
+
* — Aggregate counts + the SKUs flagged as outliers. An
|
|
57
|
+
* outlier is a row whose quantity is either zero (potential
|
|
58
|
+
* stockout) or above the 95th percentile of the snapshot
|
|
59
|
+
* (potential overstock). Returns
|
|
60
|
+
* `{ id, label, taken_at, sku_count, location_count,
|
|
61
|
+
* total_units, hash_sha3_512, hash_matches, outliers: [
|
|
62
|
+
* { sku, location_code, quantity, reason } ] }`.
|
|
63
|
+
* `hash_matches` recomputes the canonical hash from the
|
|
64
|
+
* stored rows so the caller can verify the snapshot hasn't
|
|
65
|
+
* been tampered with after the fact.
|
|
66
|
+
*
|
|
67
|
+
* purgeOlderThan(days)
|
|
68
|
+
* — Deletes every snapshot whose `taken_at < now - days*86400000`.
|
|
69
|
+
* FK CASCADE drops the row table contents with the header.
|
|
70
|
+
* Returns the count of snapshots removed.
|
|
71
|
+
*
|
|
72
|
+
* Composition:
|
|
73
|
+
* - b.uuid.v7 — snapshot + row PKs (sortable; B-tree locality)
|
|
74
|
+
* - b.guardUuid — strict UUID validation on snapshot_id reads
|
|
75
|
+
* - b.pagination — HMAC-tagged cursor for listSnapshots
|
|
76
|
+
* - b.crypto.sha3Hash — SHA3-512 canonical-row hash for tamper-evidence
|
|
77
|
+
* - catalog — single-bucket inventory read source (required)
|
|
78
|
+
* - inventoryLocations (optional) — multi-location read source
|
|
79
|
+
*
|
|
80
|
+
* Three-tier input validation discipline (use it, don't write the
|
|
81
|
+
* labels): every public verb here is a defensive request-shape
|
|
82
|
+
* reader OR a config-time entry point. Both throw on bad input.
|
|
83
|
+
* No drop-silent hot-path sinks — every snapshot creation is a
|
|
84
|
+
* deliberate operator action and a silent drop on bad input would
|
|
85
|
+
* ship a useless empty snapshot.
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
var bShop;
|
|
89
|
+
function _b() {
|
|
90
|
+
if (!bShop) bShop = require("./index");
|
|
91
|
+
return bShop.framework;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- constants ----------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
97
|
+
var LOCATION_CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
98
|
+
var LABEL_RE = /^[\S\s]{0,128}$/;
|
|
99
|
+
var MAX_LABEL_LEN = 128;
|
|
100
|
+
var MAX_REASON_LEN = 4000;
|
|
101
|
+
var MAX_LIST_LIMIT = 200;
|
|
102
|
+
var MAX_PURGE_DAYS = 365 * 100; // 100 years — practical sanity cap
|
|
103
|
+
var MAX_SKUS_PER_TAKE = 50000; // refuse pathological inputs
|
|
104
|
+
var MAX_LOCATIONS_PER_TAKE = 1000;
|
|
105
|
+
var OUTLIER_PERCENTILE = 0.95; // 95th percentile = "overstock"
|
|
106
|
+
var MAX_OUTLIERS = 50;
|
|
107
|
+
|
|
108
|
+
var SNAPSHOT_ORDER_KEY = ["taken_at:desc", "id:desc"];
|
|
109
|
+
|
|
110
|
+
// ---- validators ---------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function _id(s, label) {
|
|
113
|
+
try {
|
|
114
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
115
|
+
} catch (e) {
|
|
116
|
+
throw new TypeError("inventory-snapshots: " + label + " — " + (e && e.message || "invalid UUID"));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function _label(s) {
|
|
120
|
+
if (s == null) return "";
|
|
121
|
+
if (typeof s !== "string" || s.length > MAX_LABEL_LEN || !LABEL_RE.test(s)) {
|
|
122
|
+
throw new TypeError("inventory-snapshots: label must be a string ≤ " + MAX_LABEL_LEN + " chars");
|
|
123
|
+
}
|
|
124
|
+
return s;
|
|
125
|
+
}
|
|
126
|
+
function _reason(s) {
|
|
127
|
+
if (s == null) return "";
|
|
128
|
+
if (typeof s !== "string" || s.length > MAX_REASON_LEN) {
|
|
129
|
+
throw new TypeError("inventory-snapshots: reason must be a string ≤ " + MAX_REASON_LEN + " chars");
|
|
130
|
+
}
|
|
131
|
+
return s;
|
|
132
|
+
}
|
|
133
|
+
function _sku(s) {
|
|
134
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
135
|
+
throw new TypeError("inventory-snapshots: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function _locationCode(s, label) {
|
|
139
|
+
if (typeof s !== "string" || !LOCATION_CODE_RE.test(s)) {
|
|
140
|
+
throw new TypeError("inventory-snapshots: " + (label || "location_code") +
|
|
141
|
+
" must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function _limit(n, label) {
|
|
145
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
146
|
+
throw new TypeError("inventory-snapshots: " + label + " must be an integer in 1..." + MAX_LIST_LIMIT);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function _epochMs(n, label) {
|
|
150
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
151
|
+
throw new TypeError("inventory-snapshots: " + label + " must be a non-negative integer (epoch ms)");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function _days(n) {
|
|
155
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_PURGE_DAYS) {
|
|
156
|
+
throw new TypeError("inventory-snapshots: days must be a non-negative integer ≤ " + MAX_PURGE_DAYS);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _now() { return Date.now(); }
|
|
161
|
+
|
|
162
|
+
// Canonical row hash: SHA3-512 over the newline-joined
|
|
163
|
+
// "<sku>|<location_or_->|<qty>" lines sorted by (sku, location_code).
|
|
164
|
+
// The deterministic ordering ensures the hash is reproducible
|
|
165
|
+
// regardless of which order the source rows arrived in. A NULL
|
|
166
|
+
// location_code is rendered as the literal "-" so the encoding is
|
|
167
|
+
// unambiguous between "row with location_code = NULL" and a row
|
|
168
|
+
// whose location_code is the string "null".
|
|
169
|
+
function _hashRows(rows) {
|
|
170
|
+
var copy = rows.slice().sort(function (a, b) {
|
|
171
|
+
if (a.sku < b.sku) return -1;
|
|
172
|
+
if (a.sku > b.sku) return 1;
|
|
173
|
+
var al = a.location_code == null ? "" : a.location_code;
|
|
174
|
+
var bl = b.location_code == null ? "" : b.location_code;
|
|
175
|
+
if (al < bl) return -1;
|
|
176
|
+
if (al > bl) return 1;
|
|
177
|
+
return 0;
|
|
178
|
+
});
|
|
179
|
+
var parts = [];
|
|
180
|
+
for (var i = 0; i < copy.length; i += 1) {
|
|
181
|
+
var loc = copy[i].location_code == null ? "-" : copy[i].location_code;
|
|
182
|
+
parts.push(copy[i].sku + "|" + loc + "|" + copy[i].quantity);
|
|
183
|
+
}
|
|
184
|
+
return _b().crypto.sha3Hash(parts.join("\n"));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---- factory ------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
function create(opts) {
|
|
190
|
+
opts = opts || {};
|
|
191
|
+
if (!opts.catalog || !opts.catalog.inventory || typeof opts.catalog.inventory.get !== "function") {
|
|
192
|
+
throw new TypeError("inventory-snapshots.create: opts.catalog with inventory.get(sku) required");
|
|
193
|
+
}
|
|
194
|
+
// The catalog handle is shape-checked at factory time so the boot
|
|
195
|
+
// wiring fails loud when a caller forgets to thread it through. The
|
|
196
|
+
// snapshot reads inventory data through the injected `query` handle
|
|
197
|
+
// directly (catalog doesn't expose a "list every SKU" verb), so the
|
|
198
|
+
// reference doesn't survive past the shape gate.
|
|
199
|
+
// inventoryLocations is optional; when wired, takeSnapshot can
|
|
200
|
+
// capture per-location detail by reading inventory_stock rows.
|
|
201
|
+
// Factory holds the handle (rather than just reading the table)
|
|
202
|
+
// so the boot wiring stays explicit — a snapshot taken against a
|
|
203
|
+
// location list must have the locations primitive present to
|
|
204
|
+
// validate the codes.
|
|
205
|
+
var invLocations = opts.inventoryLocations || null;
|
|
206
|
+
if (invLocations !== null) {
|
|
207
|
+
if (typeof invLocations !== "object" || typeof invLocations.getLocation !== "function") {
|
|
208
|
+
throw new TypeError("inventory-snapshots.create: opts.inventoryLocations, when supplied, must expose getLocation(code)");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
var query = opts.query;
|
|
212
|
+
if (!query) {
|
|
213
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
214
|
+
}
|
|
215
|
+
// Pagination cursors are HMAC-tagged so an operator can't hand-
|
|
216
|
+
// craft one to skip ahead or replay across deployments. The
|
|
217
|
+
// secret must be stable for the deployment; rotating it
|
|
218
|
+
// invalidates outstanding cursors (acceptable). Tests inject a
|
|
219
|
+
// fixed dev string; production must supply an explicit secret.
|
|
220
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
221
|
+
if (process.env.NODE_ENV === "production") {
|
|
222
|
+
throw new Error("inventory-snapshots.create: opts.cursorSecret is required in production");
|
|
223
|
+
}
|
|
224
|
+
opts.cursorSecret = "inventory-snapshots-cursor-secret-dev-only";
|
|
225
|
+
}
|
|
226
|
+
var cursorSecret = opts.cursorSecret;
|
|
227
|
+
|
|
228
|
+
// Read source rows for a snapshot. When `locations` is supplied,
|
|
229
|
+
// walks `inventory_stock` (multi-location). Otherwise walks the
|
|
230
|
+
// catalog `inventory` table (single-bucket; location_code = null).
|
|
231
|
+
// `skus` narrows both paths. Returns the normalized rows array
|
|
232
|
+
// ready for insertion.
|
|
233
|
+
async function _readSource(locations, skus) {
|
|
234
|
+
var rows = [];
|
|
235
|
+
if (locations) {
|
|
236
|
+
// Multi-location capture. Validate every location code resolves
|
|
237
|
+
// to a real location row up front (refuse the snapshot rather
|
|
238
|
+
// than silently capture zero rows for a typo'd code).
|
|
239
|
+
for (var li = 0; li < locations.length; li += 1) {
|
|
240
|
+
var loc = await invLocations.getLocation(locations[li]);
|
|
241
|
+
if (!loc) {
|
|
242
|
+
throw new TypeError("inventory-snapshots.takeSnapshot: location " +
|
|
243
|
+
JSON.stringify(locations[li]) + " not found");
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Build the IN clause for locations + optional SKUs. Parameter
|
|
247
|
+
// placeholders are ?1..?N so the operator-supplied codes can't
|
|
248
|
+
// be SQL-injected.
|
|
249
|
+
var params = [];
|
|
250
|
+
var locPlaceholders = locations.map(function (c, i) { params.push(c); return "?" + (i + 1); }).join(",");
|
|
251
|
+
var sql = "SELECT sku, location_code, quantity FROM inventory_stock WHERE location_code IN (" +
|
|
252
|
+
locPlaceholders + ")";
|
|
253
|
+
if (skus && skus.length) {
|
|
254
|
+
var skuPlaceholders = skus.map(function (s, i) {
|
|
255
|
+
params.push(s);
|
|
256
|
+
return "?" + (locations.length + i + 1);
|
|
257
|
+
}).join(",");
|
|
258
|
+
sql += " AND sku IN (" + skuPlaceholders + ")";
|
|
259
|
+
}
|
|
260
|
+
sql += " ORDER BY sku ASC, location_code ASC";
|
|
261
|
+
var r = await query(sql, params);
|
|
262
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
263
|
+
rows.push({
|
|
264
|
+
sku: r.rows[i].sku,
|
|
265
|
+
location_code: r.rows[i].location_code,
|
|
266
|
+
quantity: r.rows[i].quantity,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return rows;
|
|
270
|
+
}
|
|
271
|
+
// Single-bucket capture from the catalog inventory table.
|
|
272
|
+
var sql2;
|
|
273
|
+
var params2 = [];
|
|
274
|
+
if (skus && skus.length) {
|
|
275
|
+
var ph = skus.map(function (s, i) { params2.push(s); return "?" + (i + 1); }).join(",");
|
|
276
|
+
sql2 = "SELECT sku, stock_on_hand AS quantity FROM inventory WHERE sku IN (" + ph + ") ORDER BY sku ASC";
|
|
277
|
+
} else {
|
|
278
|
+
sql2 = "SELECT sku, stock_on_hand AS quantity FROM inventory ORDER BY sku ASC";
|
|
279
|
+
}
|
|
280
|
+
var r2 = await query(sql2, params2);
|
|
281
|
+
for (var j = 0; j < r2.rows.length; j += 1) {
|
|
282
|
+
rows.push({
|
|
283
|
+
sku: r2.rows[j].sku,
|
|
284
|
+
location_code: null,
|
|
285
|
+
quantity: r2.rows[j].quantity,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return rows;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Hydrate a snapshot header + its rows. Returns null on miss so
|
|
292
|
+
// the caller-handler maps cleanly to HTTP 404.
|
|
293
|
+
async function _getHydrated(id) {
|
|
294
|
+
var hRow = await query("SELECT * FROM inventory_snapshots WHERE id = ?1", [id]);
|
|
295
|
+
if (!hRow.rows.length) return null;
|
|
296
|
+
var snapshot = hRow.rows[0];
|
|
297
|
+
var rRows = await query(
|
|
298
|
+
"SELECT * FROM inventory_snapshot_rows WHERE snapshot_id = ?1 " +
|
|
299
|
+
"ORDER BY sku ASC, location_code ASC",
|
|
300
|
+
[id],
|
|
301
|
+
);
|
|
302
|
+
snapshot.rows = rRows.rows;
|
|
303
|
+
return snapshot;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Count distinct location_codes (NULL counts as zero locations
|
|
307
|
+
// since "single-bucket" snapshots carry no per-location detail).
|
|
308
|
+
function _countLocations(rows) {
|
|
309
|
+
var seen = {};
|
|
310
|
+
var n = 0;
|
|
311
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
312
|
+
if (rows[i].location_code == null) continue;
|
|
313
|
+
if (!Object.prototype.hasOwnProperty.call(seen, rows[i].location_code)) {
|
|
314
|
+
seen[rows[i].location_code] = true;
|
|
315
|
+
n += 1;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return n;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function _countSkus(rows) {
|
|
322
|
+
var seen = {};
|
|
323
|
+
var n = 0;
|
|
324
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
325
|
+
if (!Object.prototype.hasOwnProperty.call(seen, rows[i].sku)) {
|
|
326
|
+
seen[rows[i].sku] = true;
|
|
327
|
+
n += 1;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return n;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function _sumUnits(rows) {
|
|
334
|
+
var t = 0;
|
|
335
|
+
for (var i = 0; i < rows.length; i += 1) t += rows[i].quantity;
|
|
336
|
+
return t;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
|
|
341
|
+
// Capture a point-in-time copy of the current stock counts.
|
|
342
|
+
// Persists the header + every row in one logical operation; if
|
|
343
|
+
// any insert fails, the partial header is rolled back via a
|
|
344
|
+
// compensating DELETE so the DB doesn't carry an orphaned
|
|
345
|
+
// header forward.
|
|
346
|
+
takeSnapshot: async function (input) {
|
|
347
|
+
if (!input || typeof input !== "object") {
|
|
348
|
+
throw new TypeError("inventory-snapshots.takeSnapshot: input object required");
|
|
349
|
+
}
|
|
350
|
+
var label = _label(input.label);
|
|
351
|
+
var reason = _reason(input.reason);
|
|
352
|
+
|
|
353
|
+
// Validate the optional locations + skus arrays before
|
|
354
|
+
// touching the source tables. An empty array is a separate
|
|
355
|
+
// case from "undefined" — the operator may want to take a
|
|
356
|
+
// snapshot scoped to zero locations (which produces an empty
|
|
357
|
+
// multi-location snapshot, useful for testing); we still
|
|
358
|
+
// refuse if the values themselves are malformed.
|
|
359
|
+
var locations = input.locations;
|
|
360
|
+
if (locations !== undefined && locations !== null) {
|
|
361
|
+
if (!Array.isArray(locations)) {
|
|
362
|
+
throw new TypeError("inventory-snapshots.takeSnapshot: locations must be an array of location codes");
|
|
363
|
+
}
|
|
364
|
+
if (locations.length > MAX_LOCATIONS_PER_TAKE) {
|
|
365
|
+
throw new TypeError("inventory-snapshots.takeSnapshot: locations must contain ≤ " + MAX_LOCATIONS_PER_TAKE + " entries");
|
|
366
|
+
}
|
|
367
|
+
for (var li = 0; li < locations.length; li += 1) {
|
|
368
|
+
_locationCode(locations[li], "locations[" + li + "]");
|
|
369
|
+
}
|
|
370
|
+
if (locations.length > 0 && !invLocations) {
|
|
371
|
+
throw new TypeError("inventory-snapshots.takeSnapshot: locations supplied but inventoryLocations dep not wired into the factory");
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
locations = null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
var skus = input.skus;
|
|
378
|
+
if (skus !== undefined && skus !== null) {
|
|
379
|
+
if (!Array.isArray(skus)) {
|
|
380
|
+
throw new TypeError("inventory-snapshots.takeSnapshot: skus must be an array of SKU strings");
|
|
381
|
+
}
|
|
382
|
+
if (skus.length > MAX_SKUS_PER_TAKE) {
|
|
383
|
+
throw new TypeError("inventory-snapshots.takeSnapshot: skus must contain ≤ " + MAX_SKUS_PER_TAKE + " entries");
|
|
384
|
+
}
|
|
385
|
+
for (var si = 0; si < skus.length; si += 1) {
|
|
386
|
+
_sku(skus[si]);
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
skus = null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Read the source rows BEFORE inserting the header so a bad
|
|
393
|
+
// SKU / location reference fails up front instead of leaving
|
|
394
|
+
// an empty header row behind.
|
|
395
|
+
var sourceRows = await _readSource(locations, skus);
|
|
396
|
+
|
|
397
|
+
var id = _b().uuid.v7();
|
|
398
|
+
var ts = _now();
|
|
399
|
+
var takenAt = input.taken_at == null ? ts : input.taken_at;
|
|
400
|
+
_epochMs(takenAt, "taken_at");
|
|
401
|
+
|
|
402
|
+
var skuCount = _countSkus(sourceRows);
|
|
403
|
+
var locationCount = _countLocations(sourceRows);
|
|
404
|
+
var totalUnits = _sumUnits(sourceRows);
|
|
405
|
+
var hash = _hashRows(sourceRows);
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
await query(
|
|
409
|
+
"INSERT INTO inventory_snapshots (id, label, taken_at, reason, sku_count, " +
|
|
410
|
+
"location_count, total_units, hash_sha3_512, created_at) " +
|
|
411
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
412
|
+
[id, label, takenAt, reason, skuCount, locationCount, totalUnits, hash, ts],
|
|
413
|
+
);
|
|
414
|
+
for (var i = 0; i < sourceRows.length; i += 1) {
|
|
415
|
+
var r = sourceRows[i];
|
|
416
|
+
await query(
|
|
417
|
+
"INSERT INTO inventory_snapshot_rows (id, snapshot_id, sku, location_code, quantity, captured_at) " +
|
|
418
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
419
|
+
[_b().uuid.v7(), id, r.sku, r.location_code, r.quantity, ts],
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
} catch (e) {
|
|
423
|
+
// Best-effort rollback of the header + any rows that landed
|
|
424
|
+
// before the failure (ON DELETE CASCADE on the FK clears the
|
|
425
|
+
// rows). D1 doesn't expose a transaction handle to this
|
|
426
|
+
// primitive, so the rollback is a compensating DELETE.
|
|
427
|
+
try { await query("DELETE FROM inventory_snapshots WHERE id = ?1", [id]); }
|
|
428
|
+
catch (_e2) { /* drop-silent — the original error is what the caller needs */ }
|
|
429
|
+
throw e;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
id: id,
|
|
434
|
+
label: label,
|
|
435
|
+
taken_at: takenAt,
|
|
436
|
+
sku_count: skuCount,
|
|
437
|
+
location_count: locationCount,
|
|
438
|
+
total_units: totalUnits,
|
|
439
|
+
hash_sha3_512: hash,
|
|
440
|
+
};
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
// Read a snapshot + its hydrated rows. Returns null on miss so
|
|
444
|
+
// the caller-handler maps cleanly to HTTP 404.
|
|
445
|
+
getSnapshot: async function (snapshotId) {
|
|
446
|
+
_id(snapshotId, "snapshot_id");
|
|
447
|
+
return await _getHydrated(snapshotId);
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
// Paginated list ordered (taken_at DESC, id DESC). Cursor is
|
|
451
|
+
// HMAC-tagged so an operator can't tamper or replay across
|
|
452
|
+
// orderKey changes. `from` / `to` are inclusive epoch-ms bounds.
|
|
453
|
+
listSnapshots: async function (listOpts) {
|
|
454
|
+
listOpts = listOpts || {};
|
|
455
|
+
if (listOpts.from != null) _epochMs(listOpts.from, "from");
|
|
456
|
+
if (listOpts.to != null) _epochMs(listOpts.to, "to");
|
|
457
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
458
|
+
_limit(limit, "limit");
|
|
459
|
+
var cursorVals = null;
|
|
460
|
+
if (listOpts.cursor != null) {
|
|
461
|
+
if (typeof listOpts.cursor !== "string") {
|
|
462
|
+
throw new TypeError("inventory-snapshots.listSnapshots: cursor must be an opaque string or null");
|
|
463
|
+
}
|
|
464
|
+
try {
|
|
465
|
+
var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
|
|
466
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(SNAPSHOT_ORDER_KEY)) {
|
|
467
|
+
throw new TypeError("inventory-snapshots.listSnapshots: cursor orderKey mismatch");
|
|
468
|
+
}
|
|
469
|
+
cursorVals = state.vals;
|
|
470
|
+
} catch (e) {
|
|
471
|
+
if (e instanceof TypeError) throw e;
|
|
472
|
+
throw new TypeError("inventory-snapshots.listSnapshots: cursor — " + (e && e.message || "malformed"));
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Build the WHERE clause incrementally so each optional filter
|
|
477
|
+
// (from / to / cursor) appends an indexed predicate. The
|
|
478
|
+
// (taken_at DESC) index covers the ordering + the range
|
|
479
|
+
// predicates without a sort step.
|
|
480
|
+
var clauses = [];
|
|
481
|
+
var params = [];
|
|
482
|
+
var idx = 1;
|
|
483
|
+
if (listOpts.from != null) {
|
|
484
|
+
clauses.push("taken_at >= ?" + idx); params.push(listOpts.from); idx += 1;
|
|
485
|
+
}
|
|
486
|
+
if (listOpts.to != null) {
|
|
487
|
+
clauses.push("taken_at <= ?" + idx); params.push(listOpts.to); idx += 1;
|
|
488
|
+
}
|
|
489
|
+
if (cursorVals) {
|
|
490
|
+
clauses.push("(taken_at < ?" + idx + " OR (taken_at = ?" + idx + " AND id < ?" + (idx + 1) + "))");
|
|
491
|
+
params.push(cursorVals[0]); idx += 1;
|
|
492
|
+
params.push(cursorVals[1]); idx += 1;
|
|
493
|
+
}
|
|
494
|
+
var sql = "SELECT * FROM inventory_snapshots";
|
|
495
|
+
if (clauses.length) sql += " WHERE " + clauses.join(" AND ");
|
|
496
|
+
sql += " ORDER BY taken_at DESC, id DESC LIMIT ?" + idx;
|
|
497
|
+
params.push(limit);
|
|
498
|
+
|
|
499
|
+
var rows = (await query(sql, params)).rows;
|
|
500
|
+
var last = rows[rows.length - 1];
|
|
501
|
+
var next = null;
|
|
502
|
+
if (last && rows.length === limit) {
|
|
503
|
+
next = _b().pagination.encodeCursor({
|
|
504
|
+
orderKey: SNAPSHOT_ORDER_KEY,
|
|
505
|
+
vals: [last.taken_at, last.id],
|
|
506
|
+
forward: true,
|
|
507
|
+
}, cursorSecret);
|
|
508
|
+
}
|
|
509
|
+
return { rows: rows, next_cursor: next };
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
// Compare two snapshots row-by-row. Walks both row sets, keyed
|
|
513
|
+
// by (sku, location_code), and emits one record per key with
|
|
514
|
+
// the from-quantity, to-quantity, signed delta, and a `change`
|
|
515
|
+
// classification ('added', 'removed', 'changed', 'unchanged').
|
|
516
|
+
// The optional `sku` filter limits the result to a single SKU
|
|
517
|
+
// (across every location_code it appears in).
|
|
518
|
+
deltaBetween: async function (input) {
|
|
519
|
+
if (!input || typeof input !== "object") {
|
|
520
|
+
throw new TypeError("inventory-snapshots.deltaBetween: input object required");
|
|
521
|
+
}
|
|
522
|
+
_id(input.from_snapshot_id, "from_snapshot_id");
|
|
523
|
+
_id(input.to_snapshot_id, "to_snapshot_id");
|
|
524
|
+
var skuFilter = null;
|
|
525
|
+
if (input.sku != null) {
|
|
526
|
+
_sku(input.sku);
|
|
527
|
+
skuFilter = input.sku;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
var fromSnap = await _getHydrated(input.from_snapshot_id);
|
|
531
|
+
if (!fromSnap) {
|
|
532
|
+
throw new TypeError("inventory-snapshots.deltaBetween: from_snapshot_id " +
|
|
533
|
+
input.from_snapshot_id + " not found");
|
|
534
|
+
}
|
|
535
|
+
var toSnap = await _getHydrated(input.to_snapshot_id);
|
|
536
|
+
if (!toSnap) {
|
|
537
|
+
throw new TypeError("inventory-snapshots.deltaBetween: to_snapshot_id " +
|
|
538
|
+
input.to_snapshot_id + " not found");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Index each snapshot by its (sku, location_code) key. The
|
|
542
|
+
// sentinel "" separator can't appear in a valid SKU or
|
|
543
|
+
// location_code (both validate against alphanumeric +
|
|
544
|
+
// ._- only) so the key is unambiguous.
|
|
545
|
+
function _key(sku, loc) { return sku + "" + (loc == null ? "" : loc); }
|
|
546
|
+
var fromMap = {};
|
|
547
|
+
var toMap = {};
|
|
548
|
+
var keys = {};
|
|
549
|
+
var i;
|
|
550
|
+
for (i = 0; i < fromSnap.rows.length; i += 1) {
|
|
551
|
+
if (skuFilter && fromSnap.rows[i].sku !== skuFilter) continue;
|
|
552
|
+
var k = _key(fromSnap.rows[i].sku, fromSnap.rows[i].location_code);
|
|
553
|
+
fromMap[k] = fromSnap.rows[i];
|
|
554
|
+
keys[k] = true;
|
|
555
|
+
}
|
|
556
|
+
for (i = 0; i < toSnap.rows.length; i += 1) {
|
|
557
|
+
if (skuFilter && toSnap.rows[i].sku !== skuFilter) continue;
|
|
558
|
+
var k2 = _key(toSnap.rows[i].sku, toSnap.rows[i].location_code);
|
|
559
|
+
toMap[k2] = toSnap.rows[i];
|
|
560
|
+
keys[k2] = true;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
var keyList = Object.keys(keys);
|
|
564
|
+
keyList.sort();
|
|
565
|
+
var out = [];
|
|
566
|
+
for (i = 0; i < keyList.length; i += 1) {
|
|
567
|
+
var fk = fromMap[keyList[i]];
|
|
568
|
+
var tk = toMap[keyList[i]];
|
|
569
|
+
var sku = fk ? fk.sku : tk.sku;
|
|
570
|
+
var loc = fk ? fk.location_code : tk.location_code;
|
|
571
|
+
var fromQty = fk ? fk.quantity : 0;
|
|
572
|
+
var toQty = tk ? tk.quantity : 0;
|
|
573
|
+
var change;
|
|
574
|
+
if (!fk) change = "added";
|
|
575
|
+
else if (!tk) change = "removed";
|
|
576
|
+
else if (fromQty === toQty) change = "unchanged";
|
|
577
|
+
else change = "changed";
|
|
578
|
+
out.push({
|
|
579
|
+
sku: sku,
|
|
580
|
+
location_code: loc,
|
|
581
|
+
from_quantity: fromQty,
|
|
582
|
+
to_quantity: toQty,
|
|
583
|
+
delta: toQty - fromQty,
|
|
584
|
+
change: change,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
from_snapshot_id: input.from_snapshot_id,
|
|
589
|
+
to_snapshot_id: input.to_snapshot_id,
|
|
590
|
+
rows: out,
|
|
591
|
+
};
|
|
592
|
+
},
|
|
593
|
+
|
|
594
|
+
// Snapshot summary: aggregate counts + outlier flags + tamper-
|
|
595
|
+
// evidence check via canonical-row hash recompute.
|
|
596
|
+
summary: async function (snapshotId) {
|
|
597
|
+
_id(snapshotId, "snapshot_id");
|
|
598
|
+
var snap = await _getHydrated(snapshotId);
|
|
599
|
+
if (!snap) return null;
|
|
600
|
+
|
|
601
|
+
// Tamper-evidence: re-hash the persisted rows + compare to
|
|
602
|
+
// the stored hash. A mismatch means an UPDATE landed against
|
|
603
|
+
// the row table outside this primitive (or the row table was
|
|
604
|
+
// partially restored from a backup) — either way the caller
|
|
605
|
+
// needs to know before trusting the summary.
|
|
606
|
+
var recomputed = _hashRows(snap.rows);
|
|
607
|
+
var hashMatches = recomputed === snap.hash_sha3_512;
|
|
608
|
+
|
|
609
|
+
// Outliers: zero-quantity rows (potential stockout) + rows in
|
|
610
|
+
// the top 5% of the snapshot by quantity (potential overstock).
|
|
611
|
+
// Capped at MAX_OUTLIERS to keep the response bounded even on
|
|
612
|
+
// a snapshot with thousands of zero-stock rows.
|
|
613
|
+
var quantities = snap.rows.map(function (r) { return r.quantity; }).sort(function (a, b) { return a - b; });
|
|
614
|
+
var threshold = null;
|
|
615
|
+
if (quantities.length > 0) {
|
|
616
|
+
var idx = Math.floor(quantities.length * OUTLIER_PERCENTILE);
|
|
617
|
+
if (idx >= quantities.length) idx = quantities.length - 1;
|
|
618
|
+
threshold = quantities[idx];
|
|
619
|
+
}
|
|
620
|
+
var outliers = [];
|
|
621
|
+
for (var i = 0; i < snap.rows.length && outliers.length < MAX_OUTLIERS; i += 1) {
|
|
622
|
+
var r = snap.rows[i];
|
|
623
|
+
if (r.quantity === 0) {
|
|
624
|
+
outliers.push({
|
|
625
|
+
sku: r.sku,
|
|
626
|
+
location_code: r.location_code,
|
|
627
|
+
quantity: r.quantity,
|
|
628
|
+
reason: "stockout",
|
|
629
|
+
});
|
|
630
|
+
} else if (threshold !== null && r.quantity >= threshold && quantities.length >= 20) {
|
|
631
|
+
// Only flag overstock when the snapshot is large enough
|
|
632
|
+
// for a percentile to be meaningful (< 20 rows and the
|
|
633
|
+
// 95th percentile collapses to the max — every row gets
|
|
634
|
+
// flagged, which is noise).
|
|
635
|
+
outliers.push({
|
|
636
|
+
sku: r.sku,
|
|
637
|
+
location_code: r.location_code,
|
|
638
|
+
quantity: r.quantity,
|
|
639
|
+
reason: "overstock",
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
id: snap.id,
|
|
646
|
+
label: snap.label,
|
|
647
|
+
taken_at: snap.taken_at,
|
|
648
|
+
sku_count: snap.sku_count,
|
|
649
|
+
location_count: snap.location_count,
|
|
650
|
+
total_units: snap.total_units,
|
|
651
|
+
hash_sha3_512: snap.hash_sha3_512,
|
|
652
|
+
hash_matches: hashMatches,
|
|
653
|
+
outliers: outliers,
|
|
654
|
+
};
|
|
655
|
+
},
|
|
656
|
+
|
|
657
|
+
// Delete every snapshot older than `days`. FK CASCADE drops the
|
|
658
|
+
// associated row table contents. Returns the count of snapshots
|
|
659
|
+
// removed. `days = 0` deletes everything — operators that want
|
|
660
|
+
// to wipe the snapshot history wholesale call with 0.
|
|
661
|
+
purgeOlderThan: async function (days) {
|
|
662
|
+
_days(days);
|
|
663
|
+
var cutoff = _now() - (days * 86400000);
|
|
664
|
+
// Read the ids first so the return shape can carry both the
|
|
665
|
+
// count and the ids that were removed (operators occasionally
|
|
666
|
+
// want to log "purged snapshots: a, b, c" for a compliance
|
|
667
|
+
// trail). Cap the listed ids at MAX_LIST_LIMIT to keep the
|
|
668
|
+
// response bounded; the count reflects the full delete.
|
|
669
|
+
var listed = await query(
|
|
670
|
+
"SELECT id FROM inventory_snapshots WHERE taken_at < ?1 ORDER BY taken_at ASC LIMIT ?2",
|
|
671
|
+
[cutoff, MAX_LIST_LIMIT],
|
|
672
|
+
);
|
|
673
|
+
var ids = listed.rows.map(function (r) { return r.id; });
|
|
674
|
+
var del = await query(
|
|
675
|
+
"DELETE FROM inventory_snapshots WHERE taken_at < ?1",
|
|
676
|
+
[cutoff],
|
|
677
|
+
);
|
|
678
|
+
return {
|
|
679
|
+
removed: Number(del.rowCount || 0),
|
|
680
|
+
removed_ids: ids,
|
|
681
|
+
cutoff: cutoff,
|
|
682
|
+
};
|
|
683
|
+
},
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
module.exports = {
|
|
688
|
+
create: create,
|
|
689
|
+
SNAPSHOT_ORDER_KEY: SNAPSHOT_ORDER_KEY,
|
|
690
|
+
OUTLIER_PERCENTILE: OUTLIER_PERCENTILE,
|
|
691
|
+
};
|