@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.
- package/CHANGELOG.md +6 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- 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
|
+
};
|