@blamejs/blamejs-shop 0.0.64 → 0.0.65
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/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +20 -0
- package/lib/metered-usage.js +782 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/split-shipments.js +773 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.reorderThresholds
|
|
4
|
+
* @title Reorder thresholds — automated PO suggestions from velocity + lead time
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* `inventoryAlerts` (the sibling primitive) answers "tell me when
|
|
8
|
+
* stock crosses a line". This primitive answers a different
|
|
9
|
+
* question: "given that stock has crossed the line, how much
|
|
10
|
+
* should the operator actually order, from which supplier, and
|
|
11
|
+
* what's the runway before the shelf goes empty?" The answer
|
|
12
|
+
* composes three inputs:
|
|
13
|
+
*
|
|
14
|
+
* 1. Current stock — read from `inventory_stock` (the multi-
|
|
15
|
+
* location table from migration 0034) when the threshold
|
|
16
|
+
* carries a `location_code`, otherwise from the catalog
|
|
17
|
+
* `inventory.stock_on_hand` single bucket.
|
|
18
|
+
* 2. Recent velocity — rows in `sku_velocity` are the operator's
|
|
19
|
+
* append-only log of "units sold in period". `evaluate`
|
|
20
|
+
* derives a units/day rolling average from rows whose
|
|
21
|
+
* `period_end` lands inside the last `VELOCITY_WINDOW_DAYS`
|
|
22
|
+
* days. Operators that don't supply velocity rows get
|
|
23
|
+
* `velocity = 0` — `suggested_qty` collapses to the bare
|
|
24
|
+
* gap (reorder_to - current) and `days_of_supply` reports
|
|
25
|
+
* `null`.
|
|
26
|
+
* 3. Lead time — `lead_time_days` is the operator's promise
|
|
27
|
+
* of how long the supplier needs between PO and receive.
|
|
28
|
+
* The suggested quantity adds `lead_time_days * velocity`
|
|
29
|
+
* to the gap so the operator doesn't restock to the
|
|
30
|
+
* threshold then immediately cross it again while the
|
|
31
|
+
* truck is in transit.
|
|
32
|
+
*
|
|
33
|
+
* Verbs:
|
|
34
|
+
* defineThreshold — register a (sku, location_code?)
|
|
35
|
+
* reorder rule. The partial-UNIQUE
|
|
36
|
+
* indexes on the active row set
|
|
37
|
+
* refuse duplicate (sku, location)
|
|
38
|
+
* pairs with a descriptive error
|
|
39
|
+
* up front. `location_code: null`
|
|
40
|
+
* (or omitted) is the global rule
|
|
41
|
+
* for the SKU and is enforced by
|
|
42
|
+
* its own partial UNIQUE.
|
|
43
|
+
* updateThreshold — patch any of min_stock /
|
|
44
|
+
* reorder_to / lead_time_days /
|
|
45
|
+
* vendor_slug. Refuses to drive
|
|
46
|
+
* reorder_to below min_stock — the
|
|
47
|
+
* CHECK constraint backstops, but
|
|
48
|
+
* the primitive surfaces a typed
|
|
49
|
+
* error first so the operator
|
|
50
|
+
* doesn't see a raw SQLITE_CONSTRAINT.
|
|
51
|
+
* archiveThreshold — soft-delete. Frees the (sku,
|
|
52
|
+
* location) slot for re-definition;
|
|
53
|
+
* the row stays in the table so any
|
|
54
|
+
* draft PO that captured the id
|
|
55
|
+
* still resolves.
|
|
56
|
+
* listThresholds — operator dashboard read. Filters
|
|
57
|
+
* by vendor_slug + location_code +
|
|
58
|
+
* include_archived.
|
|
59
|
+
* evaluate — pure SELECT-only computation: pulls
|
|
60
|
+
* the threshold row, the current
|
|
61
|
+
* stock, the windowed velocity, and
|
|
62
|
+
* returns { current_stock, min_stock,
|
|
63
|
+
* should_reorder, suggested_qty,
|
|
64
|
+
* days_of_supply, lead_time_days }.
|
|
65
|
+
* scanAll — every active threshold at-or-
|
|
66
|
+
* below its min_stock floor. Optional
|
|
67
|
+
* `vendor_slug` / `location_code` /
|
|
68
|
+
* `limit` narrowing for the admin
|
|
69
|
+
* "reorder queue" view.
|
|
70
|
+
* proposePurchaseOrder — aggregates `scanAll` rows whose
|
|
71
|
+
* vendor_slug matches into a draft
|
|
72
|
+
* PO payload: vendor_slug, total
|
|
73
|
+
* line count, total suggested qty,
|
|
74
|
+
* and the per-line breakdown. The
|
|
75
|
+
* primitive does NOT persist a PO —
|
|
76
|
+
* the operator's procurement pipeline
|
|
77
|
+
* takes the draft and submits it to
|
|
78
|
+
* the supplier via whatever channel
|
|
79
|
+
* (email, EDI, supplier portal) they
|
|
80
|
+
* use.
|
|
81
|
+
* recordVelocity — append a `sku_velocity` row. The
|
|
82
|
+
* operator's order-completion hook
|
|
83
|
+
* (or a nightly aggregation) writes
|
|
84
|
+
* these; `evaluate` consumes them.
|
|
85
|
+
*
|
|
86
|
+
* Composition:
|
|
87
|
+
* - b.uuid.v7 — id columns on both tables
|
|
88
|
+
* - catalog.inventory.get — fallback stock read when the
|
|
89
|
+
* threshold has no location_code
|
|
90
|
+
* - inventoryLocations — optional dep; required only when
|
|
91
|
+
* a threshold carries a location_code
|
|
92
|
+
* so the primitive can read the
|
|
93
|
+
* per-location quantity. Tests can
|
|
94
|
+
* pass `null` for inventoryLocations
|
|
95
|
+
* when every threshold is global.
|
|
96
|
+
* - vendors — held only for shape validation of
|
|
97
|
+
* `vendor_slug` references in
|
|
98
|
+
* `proposePurchaseOrder`; the slug
|
|
99
|
+
* is otherwise opaque (no FK so
|
|
100
|
+
* archived vendors still surface in
|
|
101
|
+
* listThresholds).
|
|
102
|
+
*
|
|
103
|
+
* Three-tier input validation: every public verb here is either a
|
|
104
|
+
* config-time entry point (defineThreshold, updateThreshold,
|
|
105
|
+
* archiveThreshold) or a defensive request-shape reader (evaluate,
|
|
106
|
+
* scanAll, proposePurchaseOrder, listThresholds, recordVelocity).
|
|
107
|
+
* Both shapes throw on bad input — there are no drop-silent hot-
|
|
108
|
+
* path sinks here.
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
var bShop;
|
|
112
|
+
function _b() {
|
|
113
|
+
if (!bShop) bShop = require("./index");
|
|
114
|
+
return bShop.framework;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- constants ----------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
120
|
+
var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
121
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
122
|
+
var ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
123
|
+
var DAY_MS = 24 * 60 * 60 * 1000;
|
|
124
|
+
var VELOCITY_WINDOW_DAYS = 30;
|
|
125
|
+
var MAX_LIMIT = 500;
|
|
126
|
+
var MAX_LEAD_TIME_DAYS = 3650; // 10 years — high enough to cover any real-world supplier promise; low enough to refuse a Number.MAX_SAFE_INTEGER typo
|
|
127
|
+
var MAX_STOCK = 1000000000; // a billion units — same envelope as the catalog inventory bucket
|
|
128
|
+
|
|
129
|
+
// ---- validators ---------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
function _sku(s) {
|
|
132
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
133
|
+
throw new TypeError("reorder-thresholds: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function _locationCodeOrNull(s) {
|
|
138
|
+
if (s == null) return null;
|
|
139
|
+
if (typeof s !== "string" || !CODE_RE.test(s)) {
|
|
140
|
+
throw new TypeError("reorder-thresholds: location_code must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars), or be null");
|
|
141
|
+
}
|
|
142
|
+
return s;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _vendorSlugOrNull(s) {
|
|
146
|
+
if (s == null) return null;
|
|
147
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
148
|
+
throw new TypeError("reorder-thresholds: vendor_slug must match /^[a-z0-9][a-z0-9-]*$/ (lowercase alnum + dash, 1..64 chars), or be null");
|
|
149
|
+
}
|
|
150
|
+
return s;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _vendorSlugRequired(s) {
|
|
154
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
155
|
+
throw new TypeError("reorder-thresholds: vendor_slug must match /^[a-z0-9][a-z0-9-]*$/ (lowercase alnum + dash, 1..64 chars)");
|
|
156
|
+
}
|
|
157
|
+
return s;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _stockInt(n, label) {
|
|
161
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_STOCK) {
|
|
162
|
+
throw new TypeError("reorder-thresholds: " + label + " must be a non-negative integer ≤ " + MAX_STOCK);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function _leadTimeDays(n) {
|
|
167
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_LEAD_TIME_DAYS) {
|
|
168
|
+
throw new TypeError("reorder-thresholds: lead_time_days must be a non-negative integer ≤ " + MAX_LEAD_TIME_DAYS);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _unitsSold(n) {
|
|
173
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_STOCK) {
|
|
174
|
+
throw new TypeError("reorder-thresholds: units_sold must be a non-negative integer ≤ " + MAX_STOCK);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function _epochMs(n, label) {
|
|
179
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
180
|
+
throw new TypeError("reorder-thresholds: " + label + " must be a non-negative integer (epoch ms)");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _id(s, label) {
|
|
185
|
+
if (typeof s !== "string" || !ID_RE.test(s)) {
|
|
186
|
+
throw new TypeError("reorder-thresholds: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function _limit(n) {
|
|
191
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
|
|
192
|
+
throw new TypeError("reorder-thresholds: limit must be an integer in 1.." + MAX_LIMIT);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function _now() { return Date.now(); }
|
|
197
|
+
|
|
198
|
+
// ---- factory ------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
function create(opts) {
|
|
201
|
+
opts = opts || {};
|
|
202
|
+
if (!opts.catalog || typeof opts.catalog !== "object") {
|
|
203
|
+
throw new TypeError("reorder-thresholds.create: opts.catalog is required");
|
|
204
|
+
}
|
|
205
|
+
// inventoryLocations is optional — only required when at least one
|
|
206
|
+
// threshold carries a location_code. The runtime check inside
|
|
207
|
+
// `_currentStock` surfaces a descriptive error if the operator
|
|
208
|
+
// defines a per-location threshold without wiring the dep.
|
|
209
|
+
var inventoryLocations = opts.inventoryLocations || null;
|
|
210
|
+
if (inventoryLocations !== null && typeof inventoryLocations !== "object") {
|
|
211
|
+
throw new TypeError("reorder-thresholds.create: opts.inventoryLocations must be an object or null");
|
|
212
|
+
}
|
|
213
|
+
// vendors is optional and held purely as a wiring marker; the
|
|
214
|
+
// primitive never reads from it. Operators that want strict-FK
|
|
215
|
+
// validation against the vendors table can compose the check at
|
|
216
|
+
// the caller.
|
|
217
|
+
var vendors = opts.vendors || null;
|
|
218
|
+
if (vendors !== null && typeof vendors !== "object") {
|
|
219
|
+
throw new TypeError("reorder-thresholds.create: opts.vendors must be an object or null");
|
|
220
|
+
}
|
|
221
|
+
var catalog = opts.catalog;
|
|
222
|
+
var query = opts.query;
|
|
223
|
+
if (!query) {
|
|
224
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function _shapeThreshold(row) {
|
|
228
|
+
if (!row) return null;
|
|
229
|
+
return {
|
|
230
|
+
id: row.id,
|
|
231
|
+
sku: row.sku,
|
|
232
|
+
location_code: row.location_code,
|
|
233
|
+
min_stock: row.min_stock,
|
|
234
|
+
reorder_to: row.reorder_to,
|
|
235
|
+
lead_time_days: row.lead_time_days,
|
|
236
|
+
vendor_slug: row.vendor_slug,
|
|
237
|
+
archived_at: row.archived_at,
|
|
238
|
+
created_at: row.created_at,
|
|
239
|
+
updated_at: row.updated_at,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Pull the active threshold row for a (sku, location) pair. When
|
|
244
|
+
// `location_code` is null, the SQL pulls the global row; when set,
|
|
245
|
+
// the per-location row.
|
|
246
|
+
async function _getActiveThreshold(sku, locationCode) {
|
|
247
|
+
var sql;
|
|
248
|
+
var params;
|
|
249
|
+
if (locationCode == null) {
|
|
250
|
+
sql = "SELECT * FROM reorder_thresholds " +
|
|
251
|
+
"WHERE sku = ?1 AND location_code IS NULL AND archived_at IS NULL LIMIT 1";
|
|
252
|
+
params = [sku];
|
|
253
|
+
} else {
|
|
254
|
+
sql = "SELECT * FROM reorder_thresholds " +
|
|
255
|
+
"WHERE sku = ?1 AND location_code = ?2 AND archived_at IS NULL LIMIT 1";
|
|
256
|
+
params = [sku, locationCode];
|
|
257
|
+
}
|
|
258
|
+
var r = await query(sql, params);
|
|
259
|
+
return r.rows[0] || null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function _getThresholdById(id) {
|
|
263
|
+
var r = await query("SELECT * FROM reorder_thresholds WHERE id = ?1", [id]);
|
|
264
|
+
return r.rows[0] || null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Read the current stock for (sku, location_code). The location_code
|
|
268
|
+
// path requires the inventoryLocations dep; the global path reads
|
|
269
|
+
// catalog inventory's stock_on_hand.
|
|
270
|
+
async function _currentStock(sku, locationCode) {
|
|
271
|
+
if (locationCode == null) {
|
|
272
|
+
// Catalog inventory single bucket. `catalog.inventory.get(sku)`
|
|
273
|
+
// returns null on miss — treat that as zero stock.
|
|
274
|
+
var inv = await catalog.inventory.get(sku);
|
|
275
|
+
if (!inv) return 0;
|
|
276
|
+
// The catalog primitive surfaces `stock_on_hand` directly.
|
|
277
|
+
return Number(inv.stock_on_hand) || 0;
|
|
278
|
+
}
|
|
279
|
+
if (!inventoryLocations) {
|
|
280
|
+
throw new TypeError("reorder-thresholds: threshold for sku " +
|
|
281
|
+
JSON.stringify(sku) + " carries location_code " +
|
|
282
|
+
JSON.stringify(locationCode) +
|
|
283
|
+
" but opts.inventoryLocations was not wired into the factory");
|
|
284
|
+
}
|
|
285
|
+
// The locations primitive exposes `stockForSku(sku)` returning
|
|
286
|
+
// `{ total, by_location: [...] }`. Find the matching location.
|
|
287
|
+
var stock = await inventoryLocations.stockForSku(sku);
|
|
288
|
+
var locs = stock.by_location || [];
|
|
289
|
+
for (var i = 0; i < locs.length; i += 1) {
|
|
290
|
+
if (locs[i].code === locationCode) return Number(locs[i].quantity) || 0;
|
|
291
|
+
}
|
|
292
|
+
return 0;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Sum velocity rows whose `period_end` falls inside the last
|
|
296
|
+
// VELOCITY_WINDOW_DAYS days. Returns `{ units, days }` where
|
|
297
|
+
// `days` is the elapsed span between the earliest matching
|
|
298
|
+
// period_start and `asOf` — bounded below at 1 so the divisor
|
|
299
|
+
// never goes to zero. When no rows match, returns
|
|
300
|
+
// `{ units: 0, days: 0 }` and the caller treats velocity as
|
|
301
|
+
// unknown (suggested_qty collapses to the bare gap).
|
|
302
|
+
async function _windowedVelocity(sku, locationCode, asOf) {
|
|
303
|
+
var since = asOf - VELOCITY_WINDOW_DAYS * DAY_MS;
|
|
304
|
+
var sql;
|
|
305
|
+
var params;
|
|
306
|
+
if (locationCode == null) {
|
|
307
|
+
// Global rows only — the primitive intentionally does NOT
|
|
308
|
+
// sum per-location velocity into the global computation,
|
|
309
|
+
// because that would double-count when both global and
|
|
310
|
+
// per-location operators write rows.
|
|
311
|
+
sql = "SELECT period_start, period_end, units_sold FROM sku_velocity " +
|
|
312
|
+
"WHERE sku = ?1 AND location_code IS NULL AND period_end >= ?2";
|
|
313
|
+
params = [sku, since];
|
|
314
|
+
} else {
|
|
315
|
+
sql = "SELECT period_start, period_end, units_sold FROM sku_velocity " +
|
|
316
|
+
"WHERE sku = ?1 AND location_code = ?2 AND period_end >= ?3";
|
|
317
|
+
params = [sku, locationCode, since];
|
|
318
|
+
}
|
|
319
|
+
var r = await query(sql, params);
|
|
320
|
+
if (!r.rows.length) return { units: 0, days: 0 };
|
|
321
|
+
var units = 0;
|
|
322
|
+
var earliest = Infinity;
|
|
323
|
+
var latest = -Infinity;
|
|
324
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
325
|
+
var row = r.rows[i];
|
|
326
|
+
units += Number(row.units_sold) || 0;
|
|
327
|
+
if (row.period_start < earliest) earliest = row.period_start;
|
|
328
|
+
if (row.period_end > latest) latest = row.period_end;
|
|
329
|
+
}
|
|
330
|
+
var spanMs = Math.max(latest - earliest, 0);
|
|
331
|
+
var days = Math.max(Math.round(spanMs / DAY_MS), 1);
|
|
332
|
+
return { units: units, days: days };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// The core computation: given a threshold row + current stock +
|
|
336
|
+
// windowed velocity, derive the evaluate() result.
|
|
337
|
+
function _computeEvaluation(threshold, currentStock, velocity) {
|
|
338
|
+
var unitsPerDay = velocity.days > 0 ? velocity.units / velocity.days : 0;
|
|
339
|
+
var shouldReorder = currentStock <= threshold.min_stock;
|
|
340
|
+
var leadTimeBuffer = Math.ceil(unitsPerDay * threshold.lead_time_days);
|
|
341
|
+
var gap = Math.max(threshold.reorder_to - currentStock, 0);
|
|
342
|
+
var suggestedQty = shouldReorder ? gap + leadTimeBuffer : 0;
|
|
343
|
+
var daysOfSupply;
|
|
344
|
+
if (unitsPerDay > 0) {
|
|
345
|
+
daysOfSupply = Math.floor(currentStock / unitsPerDay);
|
|
346
|
+
} else {
|
|
347
|
+
daysOfSupply = null;
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
current_stock: currentStock,
|
|
351
|
+
min_stock: threshold.min_stock,
|
|
352
|
+
should_reorder: shouldReorder,
|
|
353
|
+
suggested_qty: suggestedQty,
|
|
354
|
+
days_of_supply: daysOfSupply,
|
|
355
|
+
lead_time_days: threshold.lead_time_days,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
|
|
361
|
+
// Constants surfaced for tests + admin dashboards that want to
|
|
362
|
+
// render "velocity computed over the last N days".
|
|
363
|
+
VELOCITY_WINDOW_DAYS: VELOCITY_WINDOW_DAYS,
|
|
364
|
+
|
|
365
|
+
// Register a reorder threshold. (sku, location_code) is unique
|
|
366
|
+
// among active rows — the partial-UNIQUE indexes in the
|
|
367
|
+
// migration enforce that. The primitive checks the duplicate up
|
|
368
|
+
// front so the caller sees a typed error instead of a raw
|
|
369
|
+
// SQLITE_CONSTRAINT.
|
|
370
|
+
defineThreshold: async function (input) {
|
|
371
|
+
if (!input || typeof input !== "object") {
|
|
372
|
+
throw new TypeError("reorder-thresholds.defineThreshold: input object required");
|
|
373
|
+
}
|
|
374
|
+
_sku(input.sku);
|
|
375
|
+
var locationCode = _locationCodeOrNull(input.location_code);
|
|
376
|
+
_stockInt(input.min_stock, "min_stock");
|
|
377
|
+
_stockInt(input.reorder_to, "reorder_to");
|
|
378
|
+
if (input.reorder_to < input.min_stock) {
|
|
379
|
+
throw new TypeError("reorder-thresholds.defineThreshold: reorder_to (" +
|
|
380
|
+
input.reorder_to + ") must be ≥ min_stock (" + input.min_stock + ")");
|
|
381
|
+
}
|
|
382
|
+
_leadTimeDays(input.lead_time_days);
|
|
383
|
+
var vendorSlug = _vendorSlugOrNull(input.vendor_slug);
|
|
384
|
+
|
|
385
|
+
var existing = await _getActiveThreshold(input.sku, locationCode);
|
|
386
|
+
if (existing) {
|
|
387
|
+
throw new TypeError("reorder-thresholds.defineThreshold: an active threshold for sku " +
|
|
388
|
+
JSON.stringify(input.sku) + " location_code " +
|
|
389
|
+
JSON.stringify(locationCode) + " already exists (id " + existing.id +
|
|
390
|
+
") — patch via updateThreshold or archive it first");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
var id = _b().uuid.v7();
|
|
394
|
+
var ts = _now();
|
|
395
|
+
await query(
|
|
396
|
+
"INSERT INTO reorder_thresholds (id, sku, location_code, min_stock, reorder_to, " +
|
|
397
|
+
"lead_time_days, vendor_slug, archived_at, created_at, updated_at) " +
|
|
398
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?8)",
|
|
399
|
+
[id, input.sku, locationCode, input.min_stock, input.reorder_to,
|
|
400
|
+
input.lead_time_days, vendorSlug, ts],
|
|
401
|
+
);
|
|
402
|
+
return _shapeThreshold(await _getThresholdById(id));
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
// Patch a subset of mutable fields. `sku`, `location_code`,
|
|
406
|
+
// `created_at` are immutable; operators that need to change
|
|
407
|
+
// those archive the row and define a fresh one. Refuses any
|
|
408
|
+
// patch that would drive reorder_to below min_stock so the
|
|
409
|
+
// CHECK constraint never has to.
|
|
410
|
+
updateThreshold: async function (id, patch) {
|
|
411
|
+
_id(id, "threshold_id");
|
|
412
|
+
if (!patch || typeof patch !== "object") {
|
|
413
|
+
throw new TypeError("reorder-thresholds.updateThreshold: patch object required");
|
|
414
|
+
}
|
|
415
|
+
var existing = await _getThresholdById(id);
|
|
416
|
+
if (!existing) {
|
|
417
|
+
throw new TypeError("reorder-thresholds.updateThreshold: id " +
|
|
418
|
+
JSON.stringify(id) + " not found");
|
|
419
|
+
}
|
|
420
|
+
if (existing.archived_at != null) {
|
|
421
|
+
throw new TypeError("reorder-thresholds.updateThreshold: id " +
|
|
422
|
+
JSON.stringify(id) + " is archived — define a fresh threshold instead of patching the archived row");
|
|
423
|
+
}
|
|
424
|
+
var nextMin = existing.min_stock;
|
|
425
|
+
var nextReorderTo = existing.reorder_to;
|
|
426
|
+
var sets = [];
|
|
427
|
+
var params = [];
|
|
428
|
+
var idx = 1;
|
|
429
|
+
if (Object.prototype.hasOwnProperty.call(patch, "min_stock")) {
|
|
430
|
+
_stockInt(patch.min_stock, "min_stock");
|
|
431
|
+
nextMin = patch.min_stock;
|
|
432
|
+
sets.push("min_stock = ?" + idx); params.push(patch.min_stock); idx += 1;
|
|
433
|
+
}
|
|
434
|
+
if (Object.prototype.hasOwnProperty.call(patch, "reorder_to")) {
|
|
435
|
+
_stockInt(patch.reorder_to, "reorder_to");
|
|
436
|
+
nextReorderTo = patch.reorder_to;
|
|
437
|
+
sets.push("reorder_to = ?" + idx); params.push(patch.reorder_to); idx += 1;
|
|
438
|
+
}
|
|
439
|
+
if (nextReorderTo < nextMin) {
|
|
440
|
+
throw new TypeError("reorder-thresholds.updateThreshold: reorder_to (" +
|
|
441
|
+
nextReorderTo + ") must be ≥ min_stock (" + nextMin + ")");
|
|
442
|
+
}
|
|
443
|
+
if (Object.prototype.hasOwnProperty.call(patch, "lead_time_days")) {
|
|
444
|
+
_leadTimeDays(patch.lead_time_days);
|
|
445
|
+
sets.push("lead_time_days = ?" + idx); params.push(patch.lead_time_days); idx += 1;
|
|
446
|
+
}
|
|
447
|
+
if (Object.prototype.hasOwnProperty.call(patch, "vendor_slug")) {
|
|
448
|
+
var v = _vendorSlugOrNull(patch.vendor_slug);
|
|
449
|
+
sets.push("vendor_slug = ?" + idx); params.push(v); idx += 1;
|
|
450
|
+
}
|
|
451
|
+
if (sets.length === 0) {
|
|
452
|
+
// No-op patch — return the existing row so the admin UI
|
|
453
|
+
// can refresh.
|
|
454
|
+
return _shapeThreshold(existing);
|
|
455
|
+
}
|
|
456
|
+
sets.push("updated_at = ?" + idx); params.push(_now()); idx += 1;
|
|
457
|
+
params.push(id);
|
|
458
|
+
await query(
|
|
459
|
+
"UPDATE reorder_thresholds SET " + sets.join(", ") + " WHERE id = ?" + idx,
|
|
460
|
+
params,
|
|
461
|
+
);
|
|
462
|
+
return _shapeThreshold(await _getThresholdById(id));
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
// Soft-delete. Frees the (sku, location_code) slot among active
|
|
466
|
+
// rows so the operator can redefine; the row stays in the table
|
|
467
|
+
// so any draft PO that captured the id still resolves.
|
|
468
|
+
archiveThreshold: async function (id) {
|
|
469
|
+
_id(id, "threshold_id");
|
|
470
|
+
var existing = await _getThresholdById(id);
|
|
471
|
+
if (!existing) {
|
|
472
|
+
throw new TypeError("reorder-thresholds.archiveThreshold: id " +
|
|
473
|
+
JSON.stringify(id) + " not found");
|
|
474
|
+
}
|
|
475
|
+
if (existing.archived_at != null) {
|
|
476
|
+
// Idempotent — re-archive returns the row unchanged.
|
|
477
|
+
return _shapeThreshold(existing);
|
|
478
|
+
}
|
|
479
|
+
var ts = _now();
|
|
480
|
+
await query(
|
|
481
|
+
"UPDATE reorder_thresholds SET archived_at = ?1, updated_at = ?1 WHERE id = ?2",
|
|
482
|
+
[ts, id],
|
|
483
|
+
);
|
|
484
|
+
return _shapeThreshold(await _getThresholdById(id));
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
// Operator dashboard read. Defaults to active rows only;
|
|
488
|
+
// `include_archived: true` returns the full history.
|
|
489
|
+
listThresholds: async function (listOpts) {
|
|
490
|
+
listOpts = listOpts || {};
|
|
491
|
+
var clauses = [];
|
|
492
|
+
var params = [];
|
|
493
|
+
var idx = 1;
|
|
494
|
+
if (!listOpts.include_archived) {
|
|
495
|
+
clauses.push("archived_at IS NULL");
|
|
496
|
+
}
|
|
497
|
+
if (listOpts.vendor_slug !== undefined) {
|
|
498
|
+
if (listOpts.vendor_slug === null) {
|
|
499
|
+
clauses.push("vendor_slug IS NULL");
|
|
500
|
+
} else {
|
|
501
|
+
_vendorSlugRequired(listOpts.vendor_slug);
|
|
502
|
+
clauses.push("vendor_slug = ?" + idx); params.push(listOpts.vendor_slug); idx += 1;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (listOpts.location_code !== undefined) {
|
|
506
|
+
if (listOpts.location_code === null) {
|
|
507
|
+
clauses.push("location_code IS NULL");
|
|
508
|
+
} else {
|
|
509
|
+
var lc = _locationCodeOrNull(listOpts.location_code);
|
|
510
|
+
clauses.push("location_code = ?" + idx); params.push(lc); idx += 1;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (listOpts.sku !== undefined) {
|
|
514
|
+
_sku(listOpts.sku);
|
|
515
|
+
clauses.push("sku = ?" + idx); params.push(listOpts.sku); idx += 1;
|
|
516
|
+
}
|
|
517
|
+
var where = clauses.length ? "WHERE " + clauses.join(" AND ") : "";
|
|
518
|
+
var sql = "SELECT * FROM reorder_thresholds " + where +
|
|
519
|
+
" ORDER BY sku ASC, COALESCE(location_code, '') ASC";
|
|
520
|
+
var r = await query(sql, params);
|
|
521
|
+
return r.rows.map(_shapeThreshold);
|
|
522
|
+
},
|
|
523
|
+
|
|
524
|
+
// Pure SELECT-only computation. Resolves the threshold for
|
|
525
|
+
// (sku, location_code?), reads the current stock + windowed
|
|
526
|
+
// velocity, and returns the structured evaluation.
|
|
527
|
+
evaluate: async function (input) {
|
|
528
|
+
if (!input || typeof input !== "object") {
|
|
529
|
+
throw new TypeError("reorder-thresholds.evaluate: input object required");
|
|
530
|
+
}
|
|
531
|
+
_sku(input.sku);
|
|
532
|
+
var locationCode = _locationCodeOrNull(input.location_code);
|
|
533
|
+
var threshold = await _getActiveThreshold(input.sku, locationCode);
|
|
534
|
+
if (!threshold) {
|
|
535
|
+
throw new TypeError("reorder-thresholds.evaluate: no active threshold for sku " +
|
|
536
|
+
JSON.stringify(input.sku) + " location_code " + JSON.stringify(locationCode));
|
|
537
|
+
}
|
|
538
|
+
var asOf = input.as_of == null ? _now() : input.as_of;
|
|
539
|
+
_epochMs(asOf, "as_of");
|
|
540
|
+
var currentStock = await _currentStock(input.sku, locationCode);
|
|
541
|
+
var velocity = await _windowedVelocity(input.sku, locationCode, asOf);
|
|
542
|
+
return _computeEvaluation(threshold, currentStock, velocity);
|
|
543
|
+
},
|
|
544
|
+
|
|
545
|
+
// Walk every active threshold, evaluate each, return the ones
|
|
546
|
+
// at or below their min_stock floor. Optional filters narrow
|
|
547
|
+
// the scope; the default sweeps every threshold (capped at
|
|
548
|
+
// `limit` for unbounded callers).
|
|
549
|
+
scanAll: async function (input) {
|
|
550
|
+
input = input || {};
|
|
551
|
+
var asOf = input.as_of == null ? _now() : input.as_of;
|
|
552
|
+
_epochMs(asOf, "as_of");
|
|
553
|
+
var limit = input.limit == null ? MAX_LIMIT : input.limit;
|
|
554
|
+
_limit(limit);
|
|
555
|
+
var clauses = ["archived_at IS NULL"];
|
|
556
|
+
var params = [];
|
|
557
|
+
var idx = 1;
|
|
558
|
+
if (input.vendor_slug !== undefined) {
|
|
559
|
+
if (input.vendor_slug === null) {
|
|
560
|
+
clauses.push("vendor_slug IS NULL");
|
|
561
|
+
} else {
|
|
562
|
+
_vendorSlugRequired(input.vendor_slug);
|
|
563
|
+
clauses.push("vendor_slug = ?" + idx); params.push(input.vendor_slug); idx += 1;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (input.location_code !== undefined) {
|
|
567
|
+
if (input.location_code === null) {
|
|
568
|
+
clauses.push("location_code IS NULL");
|
|
569
|
+
} else {
|
|
570
|
+
var lc = _locationCodeOrNull(input.location_code);
|
|
571
|
+
clauses.push("location_code = ?" + idx); params.push(lc); idx += 1;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
params.push(limit);
|
|
575
|
+
var sql = "SELECT * FROM reorder_thresholds WHERE " + clauses.join(" AND ") +
|
|
576
|
+
" ORDER BY sku ASC, COALESCE(location_code, '') ASC LIMIT ?" + idx;
|
|
577
|
+
var r = await query(sql, params);
|
|
578
|
+
var out = [];
|
|
579
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
580
|
+
var threshold = r.rows[i];
|
|
581
|
+
var currentStock = await _currentStock(threshold.sku, threshold.location_code);
|
|
582
|
+
var velocity = await _windowedVelocity(threshold.sku, threshold.location_code, asOf);
|
|
583
|
+
var evalRow = _computeEvaluation(threshold, currentStock, velocity);
|
|
584
|
+
if (!evalRow.should_reorder) continue;
|
|
585
|
+
out.push({
|
|
586
|
+
threshold_id: threshold.id,
|
|
587
|
+
sku: threshold.sku,
|
|
588
|
+
location_code: threshold.location_code,
|
|
589
|
+
vendor_slug: threshold.vendor_slug,
|
|
590
|
+
current_stock: evalRow.current_stock,
|
|
591
|
+
min_stock: evalRow.min_stock,
|
|
592
|
+
suggested_qty: evalRow.suggested_qty,
|
|
593
|
+
days_of_supply: evalRow.days_of_supply,
|
|
594
|
+
lead_time_days: evalRow.lead_time_days,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
return out;
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
// Aggregate `scanAll` rows tagged with a specific vendor_slug
|
|
601
|
+
// into a draft PO. Returns the structured payload — the
|
|
602
|
+
// operator's procurement pipeline decides how to deliver it
|
|
603
|
+
// (email, EDI, supplier portal). Refuses an empty vendor_slug;
|
|
604
|
+
// returns `{ lines: [], total_qty: 0 }` when no thresholds for
|
|
605
|
+
// that vendor are at-or-below.
|
|
606
|
+
proposePurchaseOrder: async function (input) {
|
|
607
|
+
if (!input || typeof input !== "object") {
|
|
608
|
+
throw new TypeError("reorder-thresholds.proposePurchaseOrder: input object required");
|
|
609
|
+
}
|
|
610
|
+
_vendorSlugRequired(input.vendor_slug);
|
|
611
|
+
var scope = input.scope || {};
|
|
612
|
+
var scanInput = {
|
|
613
|
+
vendor_slug: input.vendor_slug,
|
|
614
|
+
as_of: scope.as_of,
|
|
615
|
+
limit: scope.limit,
|
|
616
|
+
};
|
|
617
|
+
if (scope.location_code !== undefined) scanInput.location_code = scope.location_code;
|
|
618
|
+
var candidates = await this.scanAll(scanInput);
|
|
619
|
+
var totalQty = 0;
|
|
620
|
+
var lines = candidates.map(function (c) {
|
|
621
|
+
totalQty += c.suggested_qty;
|
|
622
|
+
return {
|
|
623
|
+
threshold_id: c.threshold_id,
|
|
624
|
+
sku: c.sku,
|
|
625
|
+
location_code: c.location_code,
|
|
626
|
+
current_stock: c.current_stock,
|
|
627
|
+
min_stock: c.min_stock,
|
|
628
|
+
suggested_qty: c.suggested_qty,
|
|
629
|
+
lead_time_days: c.lead_time_days,
|
|
630
|
+
};
|
|
631
|
+
});
|
|
632
|
+
return {
|
|
633
|
+
vendor_slug: input.vendor_slug,
|
|
634
|
+
line_count: lines.length,
|
|
635
|
+
total_qty: totalQty,
|
|
636
|
+
lines: lines,
|
|
637
|
+
proposed_at: scope.as_of == null ? _now() : scope.as_of,
|
|
638
|
+
};
|
|
639
|
+
},
|
|
640
|
+
|
|
641
|
+
// Append a velocity row. Operators write these from order-
|
|
642
|
+
// completion hooks or nightly aggregation jobs; `evaluate`
|
|
643
|
+
// consumes them via the windowed sum.
|
|
644
|
+
recordVelocity: async function (input) {
|
|
645
|
+
if (!input || typeof input !== "object") {
|
|
646
|
+
throw new TypeError("reorder-thresholds.recordVelocity: input object required");
|
|
647
|
+
}
|
|
648
|
+
_sku(input.sku);
|
|
649
|
+
var locationCode = _locationCodeOrNull(input.location_code);
|
|
650
|
+
_epochMs(input.period_start, "period_start");
|
|
651
|
+
_epochMs(input.period_end, "period_end");
|
|
652
|
+
if (input.period_end < input.period_start) {
|
|
653
|
+
throw new TypeError("reorder-thresholds.recordVelocity: period_end (" +
|
|
654
|
+
input.period_end + ") must be ≥ period_start (" + input.period_start + ")");
|
|
655
|
+
}
|
|
656
|
+
_unitsSold(input.units_sold);
|
|
657
|
+
var id = _b().uuid.v7();
|
|
658
|
+
await query(
|
|
659
|
+
"INSERT INTO sku_velocity (id, sku, location_code, period_start, period_end, units_sold) " +
|
|
660
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
661
|
+
[id, input.sku, locationCode, input.period_start, input.period_end, input.units_sold],
|
|
662
|
+
);
|
|
663
|
+
return {
|
|
664
|
+
id: id,
|
|
665
|
+
sku: input.sku,
|
|
666
|
+
location_code: locationCode,
|
|
667
|
+
period_start: input.period_start,
|
|
668
|
+
period_end: input.period_end,
|
|
669
|
+
units_sold: input.units_sold,
|
|
670
|
+
};
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
module.exports = {
|
|
676
|
+
create: create,
|
|
677
|
+
VELOCITY_WINDOW_DAYS: VELOCITY_WINDOW_DAYS,
|
|
678
|
+
};
|