@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.
@@ -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
+ };