@blamejs/blamejs-shop 0.0.72 → 0.0.75

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,1121 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.demandForecast
4
+ * @title Demand forecast — per-SKU forward-looking demand prediction
5
+ *
6
+ * @intro
7
+ * `reorderThresholds` derives a rolling-window units/day average
8
+ * from the operator's velocity log; `autoReplenish` consumes that
9
+ * signal to size POs. Both layers answer "how much should I order
10
+ * right now?" with a backward-looking velocity number. This
11
+ * primitive answers a related but distinct question: "given the
12
+ * SKU's history and any seasonal pattern, what's the EXPECTED
13
+ * demand over the next N days, with a confidence band?"
14
+ *
15
+ * The split matters because:
16
+ * - reorderThresholds wants a single scalar (units/day) to feed
17
+ * the lead-time padding calc; it doesn't care about variance.
18
+ * - autoReplenish wants the SAME scalar but multiplied by a
19
+ * horizon to size a PO; again, point estimate is enough.
20
+ * - Operators planning seasonal inventory, warehouse capacity,
21
+ * or cash-flow want { point, low, high, seasonal multiplier }
22
+ * — a forecast SHAPE, not a scalar.
23
+ *
24
+ * This primitive owns the forecast shape. It is intentionally
25
+ * composable into the two consumer primitives — when wired,
26
+ * reorderThresholds + autoReplenish can read `predicted_units` to
27
+ * replace their internal rolling-window math.
28
+ *
29
+ * Four model kinds ship in v1:
30
+ *
31
+ * simple_moving_average — arithmetic mean over the last
32
+ * `window_days` of history. Default
33
+ * model when no model is registered.
34
+ * Confidence band: ±1σ.
35
+ * weighted_moving_average — linearly weighted mean (most-recent
36
+ * period weight `window_days`, oldest
37
+ * weight 1). Heavier recency bias.
38
+ * Confidence band: ±1.5σ.
39
+ * exponential_smoothing — Holt-style single exponential
40
+ * smoothing with operator-supplied
41
+ * alpha (0..1). Confidence band: ±
42
+ * residual standard error.
43
+ * linear_regression — ordinary least squares on
44
+ * (period_start, units_sold). The
45
+ * forecast extrapolates the trend
46
+ * line `horizon_days` forward.
47
+ * Confidence band: ±1.96 * residual σ.
48
+ *
49
+ * Seasonal pattern: every model multiplies its point estimate by
50
+ * the SKU's seasonal factor for the forecast horizon's day-of-week
51
+ * and month, computed by `seasonalPattern` from the history. When
52
+ * the SKU has fewer than two cycles of history (< 14 days for
53
+ * weekly seasonality, < 60 days for monthly), the factor falls
54
+ * back to 1.0 — no seasonality is detected.
55
+ *
56
+ * Verbs:
57
+ * recordHistoricalDemand — append-only write of one (sku,
58
+ * location_code?, period_start,
59
+ * period_end, units_sold)
60
+ * observation. Idempotent on
61
+ * (sku, location, period_start,
62
+ * period_end) — re-recording the same
63
+ * window overwrites units_sold.
64
+ * forecastForSku — compute + persist a forecast for
65
+ * one (sku, location_code?,
66
+ * horizon_days) tuple. Returns the
67
+ * shape { predicted_units,
68
+ * confidence_low, confidence_high,
69
+ * baseline_method, seasonal_factor }.
70
+ * bulkForecast — fan `forecastForSku` over an array
71
+ * of SKUs at one horizon. Returns
72
+ * one entry per SKU including the
73
+ * fallback row when history is empty.
74
+ * topGrowingSkus — windowed read of the SKUs whose
75
+ * demand grew the most between two
76
+ * sub-windows (first-half vs
77
+ * second-half of [from, to]).
78
+ * topDecliningSkus — same shape, reversed sign.
79
+ * seasonalPattern — returns the weekly multipliers
80
+ * (7 entries indexed 0=Sunday..6=
81
+ * Saturday) and monthly multipliers
82
+ * (12 entries indexed 0=Jan..11=Dec)
83
+ * the SKU's history produces.
84
+ * defineForecastModel — register / patch a model. `slug`
85
+ * is the primary key; `kind` picks
86
+ * the algorithm; `parameters` is a
87
+ * plain object the kind interprets.
88
+ * recomputeAllForecasts — sweep every (sku, horizon) tuple
89
+ * that has at least one persisted
90
+ * forecast row and recompute it
91
+ * against the current history.
92
+ * Returns a summary of how many rows
93
+ * were updated.
94
+ *
95
+ * Three-tier input validation: every public verb is a defensive
96
+ * config-time or request-shape reader — bad input throws a typed
97
+ * TypeError. There are no drop-silent hot-path sinks; demand
98
+ * forecasting is a SELECT-heavy analytical surface, not an
99
+ * observability pipeline.
100
+ */
101
+
102
+ var bShop;
103
+ function _b() {
104
+ if (!bShop) bShop = require("./index");
105
+ return bShop.framework;
106
+ }
107
+
108
+ // ---- constants ----------------------------------------------------------
109
+
110
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
111
+ var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
112
+ var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
113
+
114
+ var MODEL_KINDS = Object.freeze([
115
+ "simple_moving_average",
116
+ "weighted_moving_average",
117
+ "exponential_smoothing",
118
+ "linear_regression",
119
+ ]);
120
+
121
+ var MAX_HORIZON_DAYS = 365;
122
+ var MAX_WINDOW_DAYS = 365;
123
+ var MAX_LIMIT = 500;
124
+ var MAX_BULK_SKUS = 500;
125
+ var MAX_UNITS_SOLD = 1000000000;
126
+
127
+ var DEFAULT_WINDOW_DAYS = 30;
128
+ var DEFAULT_ALPHA = 0.3;
129
+
130
+ var DAY_MS = 24 * 60 * 60 * 1000;
131
+
132
+ // Weekly-seasonality needs at least two full weeks of history to
133
+ // avoid claiming a pattern from one cycle. Monthly-seasonality needs
134
+ // two months.
135
+ var WEEKLY_MIN_DAYS = 14;
136
+ var MONTHLY_MIN_DAYS = 60;
137
+
138
+ // ---- monotonic clock ----------------------------------------------------
139
+ //
140
+ // `recordHistoricalDemand` and `forecastForSku` can stamp multiple
141
+ // rows in the same wall-clock millisecond (bulk operations,
142
+ // recomputeAllForecasts sweep). The history-window reads order by
143
+ // `occurred_at` / `computed_at` and tie on id; the monotonic step
144
+ // guarantees the timestamps strictly increase so the per-row
145
+ // ordering is deterministic across replays.
146
+ var _lastTs = 0;
147
+ function _now() {
148
+ var t = Date.now();
149
+ if (t <= _lastTs) { t = _lastTs + 1; }
150
+ _lastTs = t;
151
+ return t;
152
+ }
153
+
154
+ // ---- validators ---------------------------------------------------------
155
+
156
+ function _sku(s, label) {
157
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
158
+ throw new TypeError("demand-forecast: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
159
+ }
160
+ return s;
161
+ }
162
+
163
+ function _locationOrNull(s, label) {
164
+ if (s == null) return null;
165
+ if (typeof s !== "string" || !CODE_RE.test(s)) {
166
+ throw new TypeError("demand-forecast: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars) or null");
167
+ }
168
+ return s;
169
+ }
170
+
171
+ function _slug(s, label) {
172
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
173
+ throw new TypeError("demand-forecast: " + label + " must match /^[a-z0-9][a-z0-9-]*$/ (lowercase alnum + dash, 1..64 chars)");
174
+ }
175
+ return s;
176
+ }
177
+
178
+ function _epochMs(n, label) {
179
+ if (!Number.isInteger(n) || n < 0) {
180
+ throw new TypeError("demand-forecast: " + label + " must be a non-negative integer (epoch ms)");
181
+ }
182
+ return n;
183
+ }
184
+
185
+ function _unitsSold(n, label) {
186
+ if (!Number.isInteger(n) || n < 0 || n > MAX_UNITS_SOLD) {
187
+ throw new TypeError("demand-forecast: " + label + " must be a non-negative integer ≤ " + MAX_UNITS_SOLD);
188
+ }
189
+ return n;
190
+ }
191
+
192
+ function _horizonDays(n) {
193
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_HORIZON_DAYS) {
194
+ throw new TypeError("demand-forecast: horizon_days must be an integer in 1.." + MAX_HORIZON_DAYS);
195
+ }
196
+ return n;
197
+ }
198
+
199
+ function _limit(n) {
200
+ if (n == null) return MAX_LIMIT;
201
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
202
+ throw new TypeError("demand-forecast: limit must be an integer in 1.." + MAX_LIMIT);
203
+ }
204
+ return n;
205
+ }
206
+
207
+ function _modelKind(s) {
208
+ if (typeof s !== "string" || MODEL_KINDS.indexOf(s) === -1) {
209
+ throw new TypeError("demand-forecast: kind must be one of " + MODEL_KINDS.join(", "));
210
+ }
211
+ return s;
212
+ }
213
+
214
+ function _parameters(p) {
215
+ if (p == null) return {};
216
+ if (typeof p !== "object" || Array.isArray(p)) {
217
+ throw new TypeError("demand-forecast: parameters must be a plain object");
218
+ }
219
+ // Pull the knobs that any model kind might read; refuse garbage so
220
+ // the operator catches the typo at definition time.
221
+ var out = {};
222
+ if (Object.prototype.hasOwnProperty.call(p, "window_days")) {
223
+ var w = p.window_days;
224
+ if (!Number.isInteger(w) || w <= 0 || w > MAX_WINDOW_DAYS) {
225
+ throw new TypeError("demand-forecast: parameters.window_days must be an integer in 1.." + MAX_WINDOW_DAYS);
226
+ }
227
+ out.window_days = w;
228
+ }
229
+ if (Object.prototype.hasOwnProperty.call(p, "alpha")) {
230
+ var a = p.alpha;
231
+ if (typeof a !== "number" || !isFinite(a) || a <= 0 || a >= 1) {
232
+ throw new TypeError("demand-forecast: parameters.alpha must be a number in (0, 1)");
233
+ }
234
+ out.alpha = a;
235
+ }
236
+ return out;
237
+ }
238
+
239
+ // ---- row hydration ------------------------------------------------------
240
+
241
+ function _shapeHistory(row) {
242
+ if (!row) return null;
243
+ return {
244
+ id: row.id,
245
+ sku: row.sku,
246
+ location_code: row.location_code == null ? null : row.location_code,
247
+ period_start: Number(row.period_start),
248
+ period_end: Number(row.period_end),
249
+ units_sold: Number(row.units_sold),
250
+ occurred_at: Number(row.occurred_at),
251
+ };
252
+ }
253
+
254
+ function _shapeForecast(row) {
255
+ if (!row) return null;
256
+ return {
257
+ id: row.id,
258
+ sku: row.sku,
259
+ location_code: row.location_code == null ? null : row.location_code,
260
+ horizon_days: Number(row.horizon_days),
261
+ predicted_units: Number(row.predicted_units),
262
+ confidence_low: Number(row.confidence_low),
263
+ confidence_high: Number(row.confidence_high),
264
+ model_slug: row.model_slug,
265
+ seasonal_factor: Number(row.seasonal_factor),
266
+ computed_at: Number(row.computed_at),
267
+ };
268
+ }
269
+
270
+ function _shapeModel(row) {
271
+ if (!row) return null;
272
+ var params = {};
273
+ try {
274
+ params = JSON.parse(row.parameters_json) || {};
275
+ } catch (_e) {
276
+ params = {};
277
+ }
278
+ return {
279
+ slug: row.slug,
280
+ kind: row.kind,
281
+ parameters: params,
282
+ active: row.active ? true : false,
283
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
284
+ created_at: Number(row.created_at),
285
+ updated_at: Number(row.updated_at),
286
+ };
287
+ }
288
+
289
+ // ---- math primitives ----------------------------------------------------
290
+ //
291
+ // Each model produces { units_per_day, residual_sigma }. Callers
292
+ // multiply by horizon_days * seasonal_factor and apply the model's
293
+ // band multiplier to derive predicted_units / confidence_low /
294
+ // confidence_high.
295
+
296
+ function _periodDays(row) {
297
+ // Period length in days; clamp to 1 to avoid divide-by-zero when
298
+ // the operator records a same-day observation.
299
+ var span = Math.max(1, Math.round((row.period_end - row.period_start) / DAY_MS) + 1);
300
+ return span;
301
+ }
302
+
303
+ function _unitsPerDay(row) {
304
+ return row.units_sold / _periodDays(row);
305
+ }
306
+
307
+ function _simpleMovingAverage(rows) {
308
+ if (!rows.length) return { units_per_day: 0, residual_sigma: 0 };
309
+ var sum = 0;
310
+ for (var i = 0; i < rows.length; i += 1) sum += _unitsPerDay(rows[i]);
311
+ var mean = sum / rows.length;
312
+ var varSum = 0;
313
+ for (var j = 0; j < rows.length; j += 1) {
314
+ var d = _unitsPerDay(rows[j]) - mean;
315
+ varSum += d * d;
316
+ }
317
+ var sigma = Math.sqrt(varSum / rows.length);
318
+ return { units_per_day: mean, residual_sigma: sigma };
319
+ }
320
+
321
+ function _weightedMovingAverage(rows) {
322
+ if (!rows.length) return { units_per_day: 0, residual_sigma: 0 };
323
+ // rows are ordered period_end DESC (most-recent first); assign
324
+ // weight = rows.length to index 0, weight = 1 to index rows.length-1.
325
+ var totalWeight = 0;
326
+ var weightedSum = 0;
327
+ for (var i = 0; i < rows.length; i += 1) {
328
+ var w = rows.length - i;
329
+ totalWeight += w;
330
+ weightedSum += w * _unitsPerDay(rows[i]);
331
+ }
332
+ var mean = weightedSum / totalWeight;
333
+ var varSum = 0;
334
+ for (var j = 0; j < rows.length; j += 1) {
335
+ var d = _unitsPerDay(rows[j]) - mean;
336
+ varSum += d * d;
337
+ }
338
+ var sigma = Math.sqrt(varSum / rows.length);
339
+ return { units_per_day: mean, residual_sigma: sigma };
340
+ }
341
+
342
+ function _exponentialSmoothing(rows, alpha) {
343
+ if (!rows.length) return { units_per_day: 0, residual_sigma: 0 };
344
+ // rows are period_end DESC; walk oldest -> newest so the smoothed
345
+ // level reflects the most-recent observation.
346
+ var ordered = rows.slice().reverse();
347
+ var level = _unitsPerDay(ordered[0]);
348
+ var residuals = [];
349
+ for (var i = 1; i < ordered.length; i += 1) {
350
+ var obs = _unitsPerDay(ordered[i]);
351
+ residuals.push(obs - level);
352
+ level = alpha * obs + (1 - alpha) * level;
353
+ }
354
+ var varSum = 0;
355
+ for (var j = 0; j < residuals.length; j += 1) {
356
+ varSum += residuals[j] * residuals[j];
357
+ }
358
+ var sigma = residuals.length ? Math.sqrt(varSum / residuals.length) : 0;
359
+ return { units_per_day: level, residual_sigma: sigma };
360
+ }
361
+
362
+ function _linearRegression(rows) {
363
+ if (!rows.length) return { units_per_day: 0, residual_sigma: 0 };
364
+ if (rows.length === 1) {
365
+ return { units_per_day: _unitsPerDay(rows[0]), residual_sigma: 0 };
366
+ }
367
+ // OLS on (x = period_start in days from epoch, y = units_per_day).
368
+ var n = rows.length;
369
+ var sx = 0;
370
+ var sy = 0;
371
+ var sxy = 0;
372
+ var sxx = 0;
373
+ for (var i = 0; i < n; i += 1) {
374
+ var x = rows[i].period_start / DAY_MS;
375
+ var y = _unitsPerDay(rows[i]);
376
+ sx += x;
377
+ sy += y;
378
+ sxy += x * y;
379
+ sxx += x * x;
380
+ }
381
+ var denom = (n * sxx) - (sx * sx);
382
+ if (denom === 0) {
383
+ return { units_per_day: sy / n, residual_sigma: 0 };
384
+ }
385
+ var slope = ((n * sxy) - (sx * sy)) / denom;
386
+ var intercept = (sy - slope * sx) / n;
387
+ // Project to the most-recent observation's x — that's the
388
+ // "current" point on the trend line. (Caller multiplies by horizon
389
+ // for the projection.)
390
+ var mostRecentX = rows[0].period_start / DAY_MS;
391
+ var projected = intercept + slope * mostRecentX;
392
+ // Residual standard error.
393
+ var resSum = 0;
394
+ for (var j = 0; j < n; j += 1) {
395
+ var xj = rows[j].period_start / DAY_MS;
396
+ var yj = _unitsPerDay(rows[j]);
397
+ var pred = intercept + slope * xj;
398
+ resSum += (yj - pred) * (yj - pred);
399
+ }
400
+ var sigma = Math.sqrt(resSum / n);
401
+ return { units_per_day: Math.max(0, projected), residual_sigma: sigma };
402
+ }
403
+
404
+ function _runModel(kind, parameters, rows) {
405
+ if (kind === "weighted_moving_average") return _weightedMovingAverage(rows);
406
+ if (kind === "exponential_smoothing") return _exponentialSmoothing(rows, parameters.alpha || DEFAULT_ALPHA);
407
+ if (kind === "linear_regression") return _linearRegression(rows);
408
+ // Default — simple_moving_average.
409
+ return _simpleMovingAverage(rows);
410
+ }
411
+
412
+ function _bandMultiplier(kind) {
413
+ if (kind === "weighted_moving_average") return 1.5;
414
+ if (kind === "exponential_smoothing") return 1.0;
415
+ if (kind === "linear_regression") return 1.96;
416
+ return 1.0;
417
+ }
418
+
419
+ // Compute weekly + monthly seasonal multipliers from the SKU's
420
+ // history. The factor at index `i` is (mean units/day for periods
421
+ // overlapping day-of-week / month `i`) / (overall mean units/day).
422
+ // When the SKU has too little history for stable estimates, every
423
+ // entry is 1.0.
424
+ function _seasonalPattern(rows) {
425
+ var weekly = [1, 1, 1, 1, 1, 1, 1];
426
+ var monthly = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1];
427
+ if (!rows.length) return { weekly: weekly, monthly: monthly };
428
+
429
+ // Total span in days.
430
+ var minStart = rows[0].period_start;
431
+ var maxEnd = rows[0].period_end;
432
+ for (var i = 1; i < rows.length; i += 1) {
433
+ if (rows[i].period_start < minStart) minStart = rows[i].period_start;
434
+ if (rows[i].period_end > maxEnd) maxEnd = rows[i].period_end;
435
+ }
436
+ var spanDays = Math.round((maxEnd - minStart) / DAY_MS) + 1;
437
+
438
+ var totalSum = 0;
439
+ var totalCount = 0;
440
+ var dayOfWeekSums = [0, 0, 0, 0, 0, 0, 0];
441
+ var dayOfWeekCounts = [0, 0, 0, 0, 0, 0, 0];
442
+ var monthSums = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
443
+ var monthCounts = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
444
+
445
+ for (var j = 0; j < rows.length; j += 1) {
446
+ var row = rows[j];
447
+ var perDay = _unitsPerDay(row);
448
+ // Distribute the period's units/day across each day in the period.
449
+ var d = row.period_start;
450
+ while (d <= row.period_end) {
451
+ var dt = new Date(d);
452
+ var dow = dt.getUTCDay();
453
+ var mon = dt.getUTCMonth();
454
+ dayOfWeekSums[dow] += perDay;
455
+ dayOfWeekCounts[dow] += 1;
456
+ monthSums[mon] += perDay;
457
+ monthCounts[mon] += 1;
458
+ totalSum += perDay;
459
+ totalCount += 1;
460
+ d += DAY_MS;
461
+ }
462
+ }
463
+
464
+ if (totalCount === 0) return { weekly: weekly, monthly: monthly };
465
+ var overallMean = totalSum / totalCount;
466
+ if (overallMean <= 0) return { weekly: weekly, monthly: monthly };
467
+
468
+ if (spanDays >= WEEKLY_MIN_DAYS) {
469
+ for (var w = 0; w < 7; w += 1) {
470
+ if (dayOfWeekCounts[w] > 0) {
471
+ var dayMean = dayOfWeekSums[w] / dayOfWeekCounts[w];
472
+ weekly[w] = dayMean / overallMean;
473
+ }
474
+ }
475
+ }
476
+ if (spanDays >= MONTHLY_MIN_DAYS) {
477
+ for (var m = 0; m < 12; m += 1) {
478
+ if (monthCounts[m] > 0) {
479
+ var monMean = monthSums[m] / monthCounts[m];
480
+ monthly[m] = monMean / overallMean;
481
+ }
482
+ }
483
+ }
484
+ return { weekly: weekly, monthly: monthly };
485
+ }
486
+
487
+ // ---- factory ------------------------------------------------------------
488
+
489
+ function create(opts) {
490
+ opts = opts || {};
491
+
492
+ var query = opts.query;
493
+ if (!query) {
494
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
495
+ }
496
+
497
+ var order = opts.order || null;
498
+ if (order != null && typeof order !== "object") {
499
+ throw new TypeError("demand-forecast.create: opts.order must be an object or null");
500
+ }
501
+
502
+ async function _historyFor(sku, locationCode, windowDays) {
503
+ var sinceMs = _now() - (windowDays * DAY_MS);
504
+ var clauses = ["sku = ?1", "period_end >= ?2"];
505
+ var params = [sku, sinceMs];
506
+ var idx = 3;
507
+ if (locationCode == null) {
508
+ clauses.push("location_code IS NULL");
509
+ } else {
510
+ clauses.push("location_code = ?" + idx);
511
+ params.push(locationCode);
512
+ idx += 1;
513
+ }
514
+ var r = await query(
515
+ "SELECT * FROM demand_history WHERE " + clauses.join(" AND ") +
516
+ " ORDER BY period_end DESC, id DESC",
517
+ params,
518
+ );
519
+ return r.rows.map(_shapeHistory);
520
+ }
521
+
522
+ async function _allHistoryFor(sku, locationCode) {
523
+ var clauses = ["sku = ?1"];
524
+ var params = [sku];
525
+ var idx = 2;
526
+ if (locationCode == null) {
527
+ clauses.push("location_code IS NULL");
528
+ } else {
529
+ clauses.push("location_code = ?" + idx);
530
+ params.push(locationCode);
531
+ idx += 1;
532
+ }
533
+ var r = await query(
534
+ "SELECT * FROM demand_history WHERE " + clauses.join(" AND ") +
535
+ " ORDER BY period_end DESC, id DESC",
536
+ params,
537
+ );
538
+ return r.rows.map(_shapeHistory);
539
+ }
540
+
541
+ async function _activeModel() {
542
+ var r = await query(
543
+ "SELECT * FROM forecast_models WHERE active = 1 AND archived_at IS NULL " +
544
+ "ORDER BY updated_at DESC LIMIT 1",
545
+ [],
546
+ );
547
+ return r.rows.length ? _shapeModel(r.rows[0]) : null;
548
+ }
549
+
550
+ async function _findHistoryRow(sku, locationCode, periodStart, periodEnd) {
551
+ var clauses = ["sku = ?1", "period_start = ?2", "period_end = ?3"];
552
+ var params = [sku, periodStart, periodEnd];
553
+ var idx = 4;
554
+ if (locationCode == null) {
555
+ clauses.push("location_code IS NULL");
556
+ } else {
557
+ clauses.push("location_code = ?" + idx);
558
+ params.push(locationCode);
559
+ idx += 1;
560
+ }
561
+ var r = await query(
562
+ "SELECT * FROM demand_history WHERE " + clauses.join(" AND ") + " LIMIT 1",
563
+ params,
564
+ );
565
+ return r.rows[0] || null;
566
+ }
567
+
568
+ // Compute the seasonal factor that applies to a forecast horizon
569
+ // starting `from` for `horizonDays`. Average the per-day weekly +
570
+ // monthly multipliers across the forecast window so the band
571
+ // reflects the average seasonal lift over the horizon, not the
572
+ // start day alone.
573
+ function _horizonSeasonalFactor(pattern, fromMs, horizonDays) {
574
+ var sum = 0;
575
+ var count = 0;
576
+ var d = fromMs;
577
+ for (var i = 0; i < horizonDays; i += 1) {
578
+ var dt = new Date(d);
579
+ var w = pattern.weekly[dt.getUTCDay()] || 1;
580
+ var m = pattern.monthly[dt.getUTCMonth()] || 1;
581
+ sum += w * m;
582
+ count += 1;
583
+ d += DAY_MS;
584
+ }
585
+ if (count === 0) return 1;
586
+ return sum / count;
587
+ }
588
+
589
+ function _resolveModelFromInput(input, fallbackKind) {
590
+ var kind = fallbackKind || "simple_moving_average";
591
+ var parameters = {};
592
+ if (input && input.model) {
593
+ var m = input.model;
594
+ if (m.kind != null) kind = _modelKind(m.kind);
595
+ if (m.parameters != null) parameters = _parameters(m.parameters);
596
+ }
597
+ return { kind: kind, parameters: parameters };
598
+ }
599
+
600
+ async function _forecastOne(sku, locationCode, horizonDays, asOfMs, modelOverride) {
601
+ // Resolve the active model (or the override the verb passed).
602
+ var modelSlug = "default";
603
+ var kind = "simple_moving_average";
604
+ var parameters = {};
605
+ if (modelOverride) {
606
+ kind = modelOverride.kind;
607
+ parameters = modelOverride.parameters || {};
608
+ } else {
609
+ var active = await _activeModel();
610
+ if (active) {
611
+ modelSlug = active.slug;
612
+ kind = active.kind;
613
+ parameters = active.parameters || {};
614
+ }
615
+ }
616
+
617
+ var windowDays = parameters.window_days || DEFAULT_WINDOW_DAYS;
618
+ var rows = await _historyFor(sku, locationCode, windowDays);
619
+ var allRows = rows.length ? rows : await _allHistoryFor(sku, locationCode);
620
+
621
+ var pattern = _seasonalPattern(allRows);
622
+ var seasonalFactor = _horizonSeasonalFactor(pattern, asOfMs, horizonDays);
623
+
624
+ var modelResult = _runModel(kind, parameters, rows);
625
+ var unitsPerDay = modelResult.units_per_day;
626
+ var sigma = modelResult.residual_sigma;
627
+
628
+ var pointEstimate = Math.max(0, unitsPerDay * horizonDays * seasonalFactor);
629
+ var bandMult = _bandMultiplier(kind);
630
+ var bandWidth = bandMult * sigma * horizonDays * seasonalFactor;
631
+
632
+ var predicted = Math.round(pointEstimate);
633
+ var low = Math.max(0, Math.round(pointEstimate - bandWidth));
634
+ var high = Math.max(predicted, Math.round(pointEstimate + bandWidth));
635
+ if (low > predicted) low = predicted;
636
+
637
+ return {
638
+ sku: sku,
639
+ location_code: locationCode,
640
+ horizon_days: horizonDays,
641
+ predicted_units: predicted,
642
+ confidence_low: low,
643
+ confidence_high: high,
644
+ baseline_method: kind,
645
+ model_slug: modelSlug,
646
+ seasonal_factor: seasonalFactor,
647
+ };
648
+ }
649
+
650
+ return {
651
+ MODEL_KINDS: MODEL_KINDS.slice(),
652
+ MAX_HORIZON_DAYS: MAX_HORIZON_DAYS,
653
+ MAX_WINDOW_DAYS: MAX_WINDOW_DAYS,
654
+ DEFAULT_WINDOW_DAYS: DEFAULT_WINDOW_DAYS,
655
+
656
+ // Append-only (with idempotent overwrite on same window).
657
+ recordHistoricalDemand: async function (input) {
658
+ if (!input || typeof input !== "object") {
659
+ throw new TypeError("demand-forecast.recordHistoricalDemand: input object required");
660
+ }
661
+ var sku = _sku(input.sku, "sku");
662
+ var locationCode = _locationOrNull(input.location_code, "location_code");
663
+ var periodStart = _epochMs(input.period_start, "period_start");
664
+ var periodEnd = _epochMs(input.period_end, "period_end");
665
+ if (periodEnd < periodStart) {
666
+ throw new TypeError("demand-forecast.recordHistoricalDemand: period_end (" +
667
+ periodEnd + ") must be ≥ period_start (" + periodStart + ")");
668
+ }
669
+ var unitsSold = _unitsSold(input.units_sold, "units_sold");
670
+
671
+ var existing = await _findHistoryRow(sku, locationCode, periodStart, periodEnd);
672
+ var ts = _now();
673
+ if (existing) {
674
+ await query(
675
+ "UPDATE demand_history SET units_sold = ?1, occurred_at = ?2 WHERE id = ?3",
676
+ [unitsSold, ts, existing.id],
677
+ );
678
+ return _shapeHistory({
679
+ id: existing.id,
680
+ sku: sku,
681
+ location_code: locationCode,
682
+ period_start: periodStart,
683
+ period_end: periodEnd,
684
+ units_sold: unitsSold,
685
+ occurred_at: ts,
686
+ });
687
+ }
688
+ var id = _b().uuid.v7();
689
+ await query(
690
+ "INSERT INTO demand_history " +
691
+ "(id, sku, location_code, period_start, period_end, units_sold, occurred_at) " +
692
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
693
+ [id, sku, locationCode, periodStart, periodEnd, unitsSold, ts],
694
+ );
695
+ return _shapeHistory({
696
+ id: id,
697
+ sku: sku,
698
+ location_code: locationCode,
699
+ period_start: periodStart,
700
+ period_end: periodEnd,
701
+ units_sold: unitsSold,
702
+ occurred_at: ts,
703
+ });
704
+ },
705
+
706
+ forecastForSku: async function (input) {
707
+ if (!input || typeof input !== "object") {
708
+ throw new TypeError("demand-forecast.forecastForSku: input object required");
709
+ }
710
+ var sku = _sku(input.sku, "sku");
711
+ var locationCode = _locationOrNull(input.location_code, "location_code");
712
+ var horizonDays = _horizonDays(input.horizon_days);
713
+ var asOf = input.as_of == null ? _now() : _epochMs(input.as_of, "as_of");
714
+ var modelOverride = null;
715
+ if (input.model != null) {
716
+ if (typeof input.model !== "object") {
717
+ throw new TypeError("demand-forecast.forecastForSku: model must be an object");
718
+ }
719
+ modelOverride = _resolveModelFromInput(input);
720
+ }
721
+
722
+ var forecast = await _forecastOne(sku, locationCode, horizonDays, asOf, modelOverride);
723
+
724
+ // Persist the forecast row so the recompute sweep + the
725
+ // consumer-side reads can find it later.
726
+ var id = _b().uuid.v7();
727
+ var ts = _now();
728
+ await query(
729
+ "INSERT INTO demand_forecasts " +
730
+ "(id, sku, location_code, horizon_days, predicted_units, confidence_low, " +
731
+ " confidence_high, model_slug, seasonal_factor, computed_at) " +
732
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
733
+ [id, sku, locationCode, horizonDays, forecast.predicted_units,
734
+ forecast.confidence_low, forecast.confidence_high,
735
+ forecast.model_slug, forecast.seasonal_factor, ts],
736
+ );
737
+ return {
738
+ predicted_units: forecast.predicted_units,
739
+ confidence_low: forecast.confidence_low,
740
+ confidence_high: forecast.confidence_high,
741
+ baseline_method: forecast.baseline_method,
742
+ seasonal_factor: forecast.seasonal_factor,
743
+ };
744
+ },
745
+
746
+ bulkForecast: async function (input) {
747
+ if (!input || typeof input !== "object") {
748
+ throw new TypeError("demand-forecast.bulkForecast: input object required");
749
+ }
750
+ if (!Array.isArray(input.skus)) {
751
+ throw new TypeError("demand-forecast.bulkForecast: skus must be an array");
752
+ }
753
+ if (input.skus.length === 0) {
754
+ throw new TypeError("demand-forecast.bulkForecast: skus must contain at least one entry");
755
+ }
756
+ if (input.skus.length > MAX_BULK_SKUS) {
757
+ throw new TypeError("demand-forecast.bulkForecast: skus may not exceed " + MAX_BULK_SKUS + " entries");
758
+ }
759
+ var horizonDays = _horizonDays(input.horizon_days);
760
+ var asOf = input.as_of == null ? _now() : _epochMs(input.as_of, "as_of");
761
+ var locationCode = _locationOrNull(input.location_code, "location_code");
762
+
763
+ var out = [];
764
+ for (var i = 0; i < input.skus.length; i += 1) {
765
+ var sku = _sku(input.skus[i], "skus[" + i + "]");
766
+ var forecast = await _forecastOne(sku, locationCode, horizonDays, asOf, null);
767
+ var id = _b().uuid.v7();
768
+ var ts = _now();
769
+ await query(
770
+ "INSERT INTO demand_forecasts " +
771
+ "(id, sku, location_code, horizon_days, predicted_units, confidence_low, " +
772
+ " confidence_high, model_slug, seasonal_factor, computed_at) " +
773
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
774
+ [id, sku, locationCode, horizonDays, forecast.predicted_units,
775
+ forecast.confidence_low, forecast.confidence_high,
776
+ forecast.model_slug, forecast.seasonal_factor, ts],
777
+ );
778
+ out.push({
779
+ sku: sku,
780
+ predicted_units: forecast.predicted_units,
781
+ confidence_low: forecast.confidence_low,
782
+ confidence_high: forecast.confidence_high,
783
+ baseline_method: forecast.baseline_method,
784
+ seasonal_factor: forecast.seasonal_factor,
785
+ });
786
+ }
787
+ return out;
788
+ },
789
+
790
+ // Top N SKUs by demand growth between two halves of [from, to].
791
+ // The growth metric is the relative change in mean units/day
792
+ // between the first-half window and the second-half window. SKUs
793
+ // with no first-half observations are excluded so divide-by-zero
794
+ // doesn't produce a sentinel "infinite growth" winner.
795
+ topGrowingSkus: async function (input) {
796
+ if (!input || typeof input !== "object") {
797
+ throw new TypeError("demand-forecast.topGrowingSkus: input object required");
798
+ }
799
+ var from = _epochMs(input.from, "from");
800
+ var to = _epochMs(input.to, "to");
801
+ if (to <= from) {
802
+ throw new TypeError("demand-forecast.topGrowingSkus: to (" + to + ") must be > from (" + from + ")");
803
+ }
804
+ var limit = _limit(input.limit);
805
+ var mid = from + Math.floor((to - from) / 2);
806
+
807
+ var r = await query(
808
+ "SELECT sku, period_start, period_end, units_sold FROM demand_history " +
809
+ "WHERE period_end >= ?1 AND period_start <= ?2 " +
810
+ "ORDER BY sku ASC, period_end ASC",
811
+ [from, to],
812
+ );
813
+
814
+ var byFirst = {};
815
+ var byFirstC = {};
816
+ var bySecond = {};
817
+ var bySecondC = {};
818
+ for (var i = 0; i < r.rows.length; i += 1) {
819
+ var row = r.rows[i];
820
+ var perDay = Number(row.units_sold) / Math.max(1, Math.round((Number(row.period_end) - Number(row.period_start)) / DAY_MS) + 1);
821
+ if (Number(row.period_end) <= mid) {
822
+ byFirst[row.sku] = (byFirst[row.sku] || 0) + perDay;
823
+ byFirstC[row.sku] = (byFirstC[row.sku] || 0) + 1;
824
+ } else {
825
+ bySecond[row.sku] = (bySecond[row.sku] || 0) + perDay;
826
+ bySecondC[row.sku] = (bySecondC[row.sku] || 0) + 1;
827
+ }
828
+ }
829
+ var out = [];
830
+ var keys = Object.keys(bySecond);
831
+ for (var j = 0; j < keys.length; j += 1) {
832
+ var sku = keys[j];
833
+ if (!byFirst[sku] || byFirstC[sku] === 0) continue;
834
+ var firstMean = byFirst[sku] / byFirstC[sku];
835
+ var secondMean = bySecond[sku] / bySecondC[sku];
836
+ if (firstMean <= 0) continue;
837
+ var growth = (secondMean - firstMean) / firstMean;
838
+ out.push({
839
+ sku: sku,
840
+ growth_pct: growth,
841
+ first_period_avg: firstMean,
842
+ second_period_avg: secondMean,
843
+ });
844
+ }
845
+ out.sort(function (a, b) { return b.growth_pct - a.growth_pct; });
846
+ return out.slice(0, limit);
847
+ },
848
+
849
+ topDecliningSkus: async function (input) {
850
+ if (!input || typeof input !== "object") {
851
+ throw new TypeError("demand-forecast.topDecliningSkus: input object required");
852
+ }
853
+ var from = _epochMs(input.from, "from");
854
+ var to = _epochMs(input.to, "to");
855
+ if (to <= from) {
856
+ throw new TypeError("demand-forecast.topDecliningSkus: to (" + to + ") must be > from (" + from + ")");
857
+ }
858
+ var limit = _limit(input.limit);
859
+ var mid = from + Math.floor((to - from) / 2);
860
+
861
+ var r = await query(
862
+ "SELECT sku, period_start, period_end, units_sold FROM demand_history " +
863
+ "WHERE period_end >= ?1 AND period_start <= ?2 " +
864
+ "ORDER BY sku ASC, period_end ASC",
865
+ [from, to],
866
+ );
867
+
868
+ var byFirst = {};
869
+ var byFirstC = {};
870
+ var bySecond = {};
871
+ var bySecondC = {};
872
+ for (var i = 0; i < r.rows.length; i += 1) {
873
+ var row = r.rows[i];
874
+ var perDay = Number(row.units_sold) / Math.max(1, Math.round((Number(row.period_end) - Number(row.period_start)) / DAY_MS) + 1);
875
+ if (Number(row.period_end) <= mid) {
876
+ byFirst[row.sku] = (byFirst[row.sku] || 0) + perDay;
877
+ byFirstC[row.sku] = (byFirstC[row.sku] || 0) + 1;
878
+ } else {
879
+ bySecond[row.sku] = (bySecond[row.sku] || 0) + perDay;
880
+ bySecondC[row.sku] = (bySecondC[row.sku] || 0) + 1;
881
+ }
882
+ }
883
+ var out = [];
884
+ var keys = Object.keys(byFirst);
885
+ for (var j = 0; j < keys.length; j += 1) {
886
+ var sku = keys[j];
887
+ if (byFirstC[sku] === 0) continue;
888
+ var firstMean = byFirst[sku] / byFirstC[sku];
889
+ if (firstMean <= 0) continue;
890
+ var secondMean = (bySecond[sku] && bySecondC[sku] > 0)
891
+ ? bySecond[sku] / bySecondC[sku]
892
+ : 0;
893
+ var decline = (secondMean - firstMean) / firstMean;
894
+ if (decline >= 0) continue;
895
+ out.push({
896
+ sku: sku,
897
+ decline_pct: decline,
898
+ first_period_avg: firstMean,
899
+ second_period_avg: secondMean,
900
+ });
901
+ }
902
+ out.sort(function (a, b) { return a.decline_pct - b.decline_pct; });
903
+ return out.slice(0, limit);
904
+ },
905
+
906
+ seasonalPattern: async function (input) {
907
+ if (!input || typeof input !== "object") {
908
+ throw new TypeError("demand-forecast.seasonalPattern: input object required");
909
+ }
910
+ var sku = _sku(input.sku, "sku");
911
+ var locationCode = _locationOrNull(input.location_code, "location_code");
912
+ var rows = await _allHistoryFor(sku, locationCode);
913
+ var pattern = _seasonalPattern(rows);
914
+ // Span in days — derived from the rows themselves, so the
915
+ // caller can tell whether the multipliers are placeholders
916
+ // (span too short for the corresponding cycle) or computed.
917
+ var spanDays = 0;
918
+ if (rows.length) {
919
+ var minS = rows[0].period_start;
920
+ var maxE = rows[0].period_end;
921
+ for (var i = 1; i < rows.length; i += 1) {
922
+ if (rows[i].period_start < minS) minS = rows[i].period_start;
923
+ if (rows[i].period_end > maxE) maxE = rows[i].period_end;
924
+ }
925
+ spanDays = Math.round((maxE - minS) / DAY_MS) + 1;
926
+ }
927
+ return {
928
+ weekly: pattern.weekly,
929
+ monthly: pattern.monthly,
930
+ span_days: spanDays,
931
+ weekly_detected: spanDays >= WEEKLY_MIN_DAYS,
932
+ monthly_detected: spanDays >= MONTHLY_MIN_DAYS,
933
+ };
934
+ },
935
+
936
+ defineForecastModel: async function (input) {
937
+ if (!input || typeof input !== "object") {
938
+ throw new TypeError("demand-forecast.defineForecastModel: input object required");
939
+ }
940
+ var slug = _slug(input.slug, "slug");
941
+ var kind = _modelKind(input.kind);
942
+ var parameters = _parameters(input.parameters);
943
+ var ts = _now();
944
+ var existingR = await query(
945
+ "SELECT * FROM forecast_models WHERE slug = ?1",
946
+ [slug],
947
+ );
948
+ var paramsJson = JSON.stringify(parameters);
949
+ if (existingR.rows.length) {
950
+ await query(
951
+ "UPDATE forecast_models SET kind = ?1, parameters_json = ?2, " +
952
+ "active = 1, archived_at = NULL, updated_at = ?3 WHERE slug = ?4",
953
+ [kind, paramsJson, ts, slug],
954
+ );
955
+ } else {
956
+ await query(
957
+ "INSERT INTO forecast_models " +
958
+ "(slug, kind, parameters_json, active, archived_at, created_at, updated_at) " +
959
+ "VALUES (?1, ?2, ?3, 1, NULL, ?4, ?4)",
960
+ [slug, kind, paramsJson, ts],
961
+ );
962
+ }
963
+ var r = await query(
964
+ "SELECT * FROM forecast_models WHERE slug = ?1",
965
+ [slug],
966
+ );
967
+ return _shapeModel(r.rows[0]);
968
+ },
969
+
970
+ recomputeAllForecasts: async function (input) {
971
+ input = input || {};
972
+ var asOf = input.as_of == null ? _now() : _epochMs(input.as_of, "as_of");
973
+ // Pull the distinct (sku, location_code, horizon_days) tuples
974
+ // that already have a persisted forecast. The sweep recomputes
975
+ // each tuple's forecast against the current history.
976
+ var r = await query(
977
+ "SELECT DISTINCT sku, location_code, horizon_days FROM demand_forecasts " +
978
+ "ORDER BY sku ASC, horizon_days ASC",
979
+ [],
980
+ );
981
+ var updated = 0;
982
+ for (var i = 0; i < r.rows.length; i += 1) {
983
+ var row = r.rows[i];
984
+ var forecast = await _forecastOne(
985
+ row.sku,
986
+ row.location_code == null ? null : row.location_code,
987
+ Number(row.horizon_days),
988
+ asOf,
989
+ null,
990
+ );
991
+ var id = _b().uuid.v7();
992
+ var ts = _now();
993
+ await query(
994
+ "INSERT INTO demand_forecasts " +
995
+ "(id, sku, location_code, horizon_days, predicted_units, confidence_low, " +
996
+ " confidence_high, model_slug, seasonal_factor, computed_at) " +
997
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
998
+ [id, forecast.sku, forecast.location_code, forecast.horizon_days,
999
+ forecast.predicted_units, forecast.confidence_low, forecast.confidence_high,
1000
+ forecast.model_slug, forecast.seasonal_factor, ts],
1001
+ );
1002
+ updated += 1;
1003
+ }
1004
+ return { recomputed: updated };
1005
+ },
1006
+ };
1007
+ }
1008
+
1009
+ // Smoke-callable run() — exercises the factory shape against an
1010
+ // in-memory query stub so the release pipeline can confirm the module
1011
+ // loads + composes without a live D1 or migration. The stub round-
1012
+ // trips recordHistoricalDemand → forecastForSku → defineForecastModel;
1013
+ // the actual analytical math lives in the layer-1 state test where
1014
+ // the full sqlite is wired.
1015
+ async function run() {
1016
+ var history = [];
1017
+ var forecasts = [];
1018
+ var models = {};
1019
+ var q = async function (sql, params) {
1020
+ params = params || [];
1021
+ var verb = sql.replace(/^\s+/, "").split(/\s+/)[0].toUpperCase();
1022
+ if (verb === "SELECT" && /FROM demand_history/.test(sql)) {
1023
+ var out = history.slice();
1024
+ if (/sku\s*=\s*\?1/.test(sql)) {
1025
+ out = out.filter(function (h) { return h.sku === params[0]; });
1026
+ }
1027
+ return { rows: out, rowCount: out.length };
1028
+ }
1029
+ if (verb === "INSERT" && /demand_history/.test(sql)) {
1030
+ history.push({
1031
+ id: params[0], sku: params[1], location_code: params[2],
1032
+ period_start: params[3], period_end: params[4],
1033
+ units_sold: params[5], occurred_at: params[6],
1034
+ });
1035
+ return { rows: [], rowCount: 1 };
1036
+ }
1037
+ if (verb === "UPDATE" && /demand_history/.test(sql)) {
1038
+ var rowId = params[params.length - 1];
1039
+ for (var i = 0; i < history.length; i += 1) {
1040
+ if (history[i].id === rowId) {
1041
+ history[i].units_sold = params[0];
1042
+ history[i].occurred_at = params[1];
1043
+ return { rows: [], rowCount: 1 };
1044
+ }
1045
+ }
1046
+ return { rows: [], rowCount: 0 };
1047
+ }
1048
+ if (verb === "SELECT" && /FROM demand_forecasts/.test(sql)) {
1049
+ return { rows: forecasts.slice(), rowCount: forecasts.length };
1050
+ }
1051
+ if (verb === "INSERT" && /demand_forecasts/.test(sql)) {
1052
+ forecasts.push({
1053
+ id: params[0], sku: params[1], location_code: params[2],
1054
+ horizon_days: params[3], predicted_units: params[4],
1055
+ confidence_low: params[5], confidence_high: params[6],
1056
+ model_slug: params[7], seasonal_factor: params[8], computed_at: params[9],
1057
+ });
1058
+ return { rows: [], rowCount: 1 };
1059
+ }
1060
+ if (verb === "SELECT" && /FROM forecast_models/.test(sql) && /slug\s*=\s*\?1/.test(sql)) {
1061
+ var m = models[params[0]];
1062
+ return { rows: m ? [m] : [], rowCount: m ? 1 : 0 };
1063
+ }
1064
+ if (verb === "SELECT" && /FROM forecast_models/.test(sql)) {
1065
+ var arr = Object.keys(models).map(function (k) { return models[k]; });
1066
+ arr = arr.filter(function (mm) { return mm.active && mm.archived_at == null; });
1067
+ return { rows: arr, rowCount: arr.length };
1068
+ }
1069
+ if (verb === "INSERT" && /forecast_models/.test(sql)) {
1070
+ models[params[0]] = {
1071
+ slug: params[0], kind: params[1], parameters_json: params[2],
1072
+ active: 1, archived_at: null, created_at: params[3], updated_at: params[3],
1073
+ };
1074
+ return { rows: [], rowCount: 1 };
1075
+ }
1076
+ if (verb === "UPDATE" && /forecast_models/.test(sql)) {
1077
+ var slug = params[params.length - 1];
1078
+ var ex = models[slug];
1079
+ if (ex) {
1080
+ ex.kind = params[0];
1081
+ ex.parameters_json = params[1];
1082
+ ex.active = 1;
1083
+ ex.archived_at = null;
1084
+ ex.updated_at = params[2];
1085
+ }
1086
+ return { rows: [], rowCount: ex ? 1 : 0 };
1087
+ }
1088
+ return { rows: [], rowCount: 0 };
1089
+ };
1090
+ var df = create({ query: q });
1091
+ var nowMs = Date.now();
1092
+ var weekMs = 7 * DAY_MS;
1093
+ await df.recordHistoricalDemand({
1094
+ sku: "SMOKE-SKU",
1095
+ period_start: nowMs - weekMs,
1096
+ period_end: nowMs,
1097
+ units_sold: 140,
1098
+ });
1099
+ var f = await df.forecastForSku({
1100
+ sku: "SMOKE-SKU",
1101
+ horizon_days: 7,
1102
+ });
1103
+ if (!f || typeof f.predicted_units !== "number") {
1104
+ throw new Error("demand-forecast.run: smoke forecastForSku round-trip failed");
1105
+ }
1106
+ await df.defineForecastModel({
1107
+ slug: "smoke-model",
1108
+ kind: "simple_moving_average",
1109
+ parameters: { window_days: 14 },
1110
+ });
1111
+ return { ok: true };
1112
+ }
1113
+
1114
+ module.exports = {
1115
+ create: create,
1116
+ run: run,
1117
+ MODEL_KINDS: MODEL_KINDS,
1118
+ MAX_HORIZON_DAYS: MAX_HORIZON_DAYS,
1119
+ MAX_WINDOW_DAYS: MAX_WINDOW_DAYS,
1120
+ DEFAULT_WINDOW_DAYS: DEFAULT_WINDOW_DAYS,
1121
+ };