@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,1048 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.smartRestocking
|
|
4
|
+
* @title Smart restocking — EOQ-style reorder quantity that composes
|
|
5
|
+
* demand forecast, reorder thresholds, cost layers and vendor
|
|
6
|
+
* lead-time signals
|
|
7
|
+
*
|
|
8
|
+
* @intro
|
|
9
|
+
* `reorderThresholds.evaluate` answers "should I order, and how
|
|
10
|
+
* much to refill the shelf?" with a velocity-times-lead-time gap
|
|
11
|
+
* calc. That's the right answer for the dashboard's "reorder queue"
|
|
12
|
+
* view — it's deterministic, cheap, and matches the operator's
|
|
13
|
+
* intuition for fast-moving SKUs.
|
|
14
|
+
*
|
|
15
|
+
* This primitive answers a related but distinct question: "what's
|
|
16
|
+
* the COST-OPTIMAL order quantity given my holding-cost rate,
|
|
17
|
+
* per-PO ordering overhead, supplier lead time variance, and target
|
|
18
|
+
* stockout protection?" The answer is the textbook Economic Order
|
|
19
|
+
* Quantity (EOQ) formula:
|
|
20
|
+
*
|
|
21
|
+
* EOQ = sqrt((2 * D * S) / H)
|
|
22
|
+
*
|
|
23
|
+
* where D = annual demand in units (predicted_units * 365 / horizon_days)
|
|
24
|
+
* S = ordering cost per PO in minor currency units
|
|
25
|
+
* H = holding cost per unit per year in minor currency
|
|
26
|
+
* units (unit_cost_minor * holding_cost_bps / 10000)
|
|
27
|
+
*
|
|
28
|
+
* Safety stock is added on top so the operator hits their target
|
|
29
|
+
* service level (probability of not stocking out during the lead
|
|
30
|
+
* time):
|
|
31
|
+
*
|
|
32
|
+
* safety_stock = z * sigma_lead_time_demand
|
|
33
|
+
*
|
|
34
|
+
* where z is the inverse-normal CDF at the requested service
|
|
35
|
+
* level (0.90 -> 1.2816, 0.95 -> 1.6449, 0.99 -> 2.3263)
|
|
36
|
+
* sigma_lead_time_demand approximates as
|
|
37
|
+
* sqrt(lead_time_days) * daily_demand_std
|
|
38
|
+
* and daily_demand_std uses the predicted_units' confidence
|
|
39
|
+
* band width as the proxy: (confidence_high -
|
|
40
|
+
* confidence_low) / (2 * z_for_band). When no band is
|
|
41
|
+
* available, falls back to 25% of mean (typical retail
|
|
42
|
+
* CV for moderately variable demand).
|
|
43
|
+
*
|
|
44
|
+
* The recommended order quantity is the sum:
|
|
45
|
+
*
|
|
46
|
+
* recommended_qty = eoq_qty + safety_stock_qty
|
|
47
|
+
*
|
|
48
|
+
* The reorder point (the inventory level at which the operator
|
|
49
|
+
* should place the next order) is:
|
|
50
|
+
*
|
|
51
|
+
* reorder_point = (daily_demand * lead_time_days) + safety_stock
|
|
52
|
+
*
|
|
53
|
+
* Cost estimate sums the unit-cost portion (recommended_qty *
|
|
54
|
+
* unit_cost_minor) plus the per-PO ordering overhead. Currency
|
|
55
|
+
* follows the cost-layer currency for the SKU; if no cost layer
|
|
56
|
+
* exists, defaults to "USD" with a zero unit-cost — the operator
|
|
57
|
+
* sees a "no-cost-data" entry in the reasoning so the gap is
|
|
58
|
+
* visible.
|
|
59
|
+
*
|
|
60
|
+
* Verbs:
|
|
61
|
+
* definePolicy({ slug, holding_cost_bps, ordering_cost_minor,
|
|
62
|
+
* default_service_level }) — register / patch a
|
|
63
|
+
* restocking policy. Re-defining the same slug patches every
|
|
64
|
+
* field in place. Operators that want one global policy
|
|
65
|
+
* define a slug like "default" and assign every SKU to it;
|
|
66
|
+
* operators that segment by velocity-class define multiple.
|
|
67
|
+
*
|
|
68
|
+
* getPolicy(slug) / listPolicies({ include_archived? })
|
|
69
|
+
* — operator dashboard reads.
|
|
70
|
+
*
|
|
71
|
+
* archivePolicy(slug) — soft-delete. SKUs already assigned to
|
|
72
|
+
* the policy keep resolving (the assignment row outlives
|
|
73
|
+
* the policy archival); operators re-assigning a SKU pick a
|
|
74
|
+
* non-archived target.
|
|
75
|
+
*
|
|
76
|
+
* applyPolicy({ slug, sku }) — bind a SKU to a policy. Idempotent
|
|
77
|
+
* re-bind for the same (slug, sku) refreshes `assigned_at`;
|
|
78
|
+
* re-binding to a different slug replaces the existing
|
|
79
|
+
* assignment.
|
|
80
|
+
*
|
|
81
|
+
* unassignPolicy({ sku }) — remove the SKU's policy binding.
|
|
82
|
+
* `recommendOrderQty` falls back to the default policy
|
|
83
|
+
* (holding_cost_bps = 0, ordering_cost_minor = 0,
|
|
84
|
+
* default_service_level = 0.95) when no binding exists.
|
|
85
|
+
*
|
|
86
|
+
* policyForSku(sku) — read the (sku, policy) row + the resolved
|
|
87
|
+
* policy shape. Returns null when no assignment exists.
|
|
88
|
+
*
|
|
89
|
+
* recommendOrderQty({ sku, location_code?, service_level?,
|
|
90
|
+
* horizon_days? }) — the core verb. Composes:
|
|
91
|
+
* - demandForecast.forecastForSku to get predicted_units +
|
|
92
|
+
* confidence band
|
|
93
|
+
* - reorderThresholds (via `evaluate` or `getThreshold`) to
|
|
94
|
+
* read lead_time_days + current_stock
|
|
95
|
+
* - costLayers.currentLayers to derive unit_cost_minor +
|
|
96
|
+
* currency (volume-weighted average of active FIFO layers)
|
|
97
|
+
* - vendors.getVendor (optional) for vendor-level lead-time
|
|
98
|
+
* hints when reorderThresholds doesn't carry one
|
|
99
|
+
* Returns { recommended_qty, eoq_qty, safety_stock_qty,
|
|
100
|
+
* reorder_point, days_of_supply, cost_estimate_minor,
|
|
101
|
+
* currency, reasoning } and persists a recommendation row.
|
|
102
|
+
*
|
|
103
|
+
* bulkRecommend({ skus, service_level?, horizon_days?,
|
|
104
|
+
* location_code? }) — fan-out `recommendOrderQty`
|
|
105
|
+
* over an array of SKUs at one service level. Returns one
|
|
106
|
+
* entry per SKU including any { sku, error } shape when a
|
|
107
|
+
* single recommendation fails (one bad SKU doesn't block the
|
|
108
|
+
* batch).
|
|
109
|
+
*
|
|
110
|
+
* metricsForSku({ sku, from, to }) — windowed read of every
|
|
111
|
+
* recommendation row in [from, to] for the SKU, with the
|
|
112
|
+
* summary shape { count, avg_recommended_qty, avg_eoq_qty,
|
|
113
|
+
* avg_safety_stock_qty, total_cost_estimate_minor, currency,
|
|
114
|
+
* rows: [...] }. Mixed-currency windows return currency=null
|
|
115
|
+
* and total_cost_estimate_minor=null with the per-row breakdown
|
|
116
|
+
* intact (the operator's FX policy decides combination).
|
|
117
|
+
*
|
|
118
|
+
* Composition shape (every dep injected at the factory; the verbs
|
|
119
|
+
* degrade gracefully — a missing demandForecast collapses the
|
|
120
|
+
* primitive to a "no-forecast" reasoning row with the bare gap;
|
|
121
|
+
* a missing costLayers collapses cost_estimate_minor to 0 with a
|
|
122
|
+
* "no-cost-data" tag):
|
|
123
|
+
*
|
|
124
|
+
* - query — D1 handle for the three tables
|
|
125
|
+
* - demandForecast — `forecastForSku(...)` for predicted_units
|
|
126
|
+
* - reorderThresholds — `evaluate(...)` for current_stock + lead_time
|
|
127
|
+
* - costLayers — `currentLayers({ sku })` for unit cost
|
|
128
|
+
* - vendors — optional fallback lead-time when the
|
|
129
|
+
* threshold's value is missing
|
|
130
|
+
* - b.uuid.v7 — recommendation row ids
|
|
131
|
+
*
|
|
132
|
+
* Three-tier input validation: every verb is a config-time entry
|
|
133
|
+
* point (definePolicy, archivePolicy, applyPolicy, unassignPolicy)
|
|
134
|
+
* or a defensive request-shape reader (recommendOrderQty,
|
|
135
|
+
* bulkRecommend, metricsForSku, getPolicy, listPolicies,
|
|
136
|
+
* policyForSku) — bad input throws a typed TypeError. There are
|
|
137
|
+
* no drop-silent hot-path sinks; restocking is an operator-driven
|
|
138
|
+
* analytical surface, not an observability pipeline.
|
|
139
|
+
*
|
|
140
|
+
* @primitive smartRestocking
|
|
141
|
+
* @related shop.demandForecast, shop.reorderThresholds,
|
|
142
|
+
* shop.costLayers, shop.vendors, shop.autoReplenish,
|
|
143
|
+
* b.uuid.v7
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
var bShop;
|
|
147
|
+
function _b() {
|
|
148
|
+
if (!bShop) bShop = require("./index");
|
|
149
|
+
return bShop.framework;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---- constants ----------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
155
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
156
|
+
var LOCATION_RE = /^[A-Z0-9][A-Z0-9_-]{0,63}$/;
|
|
157
|
+
|
|
158
|
+
var MAX_HOLDING_BPS = 100000; // 1000% / yr — operator's upper bound; the migration CHECK enforces too
|
|
159
|
+
var MAX_ORDERING_COST_MINOR = 1000000000; // 1e9 minor units — sanity ceiling
|
|
160
|
+
var MAX_BULK_SKUS = 500;
|
|
161
|
+
var MIN_HORIZON_DAYS = 1;
|
|
162
|
+
var MAX_HORIZON_DAYS = 365;
|
|
163
|
+
var DEFAULT_HORIZON_DAYS = 30;
|
|
164
|
+
|
|
165
|
+
var SERVICE_LEVELS = Object.freeze([0.90, 0.95, 0.99]);
|
|
166
|
+
|
|
167
|
+
// Inverse-normal CDF (z-score) at the three supported service levels.
|
|
168
|
+
// These are the textbook one-tailed values; the safety-stock formula
|
|
169
|
+
// scales daily-demand sigma by sqrt(lead_time_days) and multiplies by
|
|
170
|
+
// the chosen z.
|
|
171
|
+
var Z_SCORE = Object.freeze({
|
|
172
|
+
0.90: 1.2816,
|
|
173
|
+
0.95: 1.6449,
|
|
174
|
+
0.99: 2.3263,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// z-score used inside demandForecast to derive confidence_low /
|
|
178
|
+
// confidence_high. The forecast primitive's simple_moving_average
|
|
179
|
+
// model ships a ±1σ band, but other models use 1.5σ / 1.96σ. The
|
|
180
|
+
// safer default for the band-to-sigma conversion is the SMA's 1.0σ
|
|
181
|
+
// because (high - low) / 2 already equals 1σ in that shape. Operators
|
|
182
|
+
// surfacing a different model accept the small over-estimate; tests
|
|
183
|
+
// pin the value so changes are visible.
|
|
184
|
+
var FORECAST_BAND_Z = 1.0;
|
|
185
|
+
|
|
186
|
+
// Fallback coefficient of variation when the forecast doesn't carry
|
|
187
|
+
// a confidence band (e.g. cold-start SKU with one observation).
|
|
188
|
+
// 0.25 = 25% — typical retail SKU.
|
|
189
|
+
var FALLBACK_CV = 0.25;
|
|
190
|
+
|
|
191
|
+
var FALLBACK_LEAD_TIME_DAYS = 7;
|
|
192
|
+
var FALLBACK_CURRENCY = "USD";
|
|
193
|
+
|
|
194
|
+
// Default policy used when a SKU has no assignment. Picks a service
|
|
195
|
+
// level that matches industry norms (95% — the typical retail SLA)
|
|
196
|
+
// and zeroes out the holding/ordering costs so the EOQ collapses to
|
|
197
|
+
// "just buy the gap" — the operator's signal to define a real policy.
|
|
198
|
+
var DEFAULT_POLICY = Object.freeze({
|
|
199
|
+
slug: "__default__",
|
|
200
|
+
holding_cost_bps: 0,
|
|
201
|
+
ordering_cost_minor: 0,
|
|
202
|
+
default_service_level: 0.95,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---- monotonic clock ----------------------------------------------------
|
|
206
|
+
//
|
|
207
|
+
// `recommendOrderQty` is invoked from operator dashboards + batch
|
|
208
|
+
// jobs; the per-process monotonic clock guarantees recommendation rows
|
|
209
|
+
// emitted in the same millisecond carry strictly-increasing
|
|
210
|
+
// `computed_at` values so the (sku, computed_at DESC) history sort is
|
|
211
|
+
// deterministic across replays.
|
|
212
|
+
var _lastTs = 0;
|
|
213
|
+
function _now() {
|
|
214
|
+
var t = Date.now();
|
|
215
|
+
if (t <= _lastTs) t = _lastTs + 1;
|
|
216
|
+
_lastTs = t;
|
|
217
|
+
return t;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---- validators ---------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
function _slug(s, label) {
|
|
223
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
224
|
+
throw new TypeError("smart-restocking: " + label + " must match /^[a-z0-9][a-z0-9-]*$/ (lowercase alnum + dash, 1..64 chars)");
|
|
225
|
+
}
|
|
226
|
+
return s;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function _sku(s, label) {
|
|
230
|
+
label = label || "sku";
|
|
231
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
232
|
+
throw new TypeError("smart-restocking: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (1..128 chars)");
|
|
233
|
+
}
|
|
234
|
+
return s;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function _locationOrNull(s) {
|
|
238
|
+
if (s == null) return null;
|
|
239
|
+
if (typeof s !== "string" || !LOCATION_RE.test(s)) {
|
|
240
|
+
throw new TypeError("smart-restocking: location_code must match /^[A-Z0-9][A-Z0-9_-]*$/ (1..64 chars)");
|
|
241
|
+
}
|
|
242
|
+
return s;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function _holdingBps(n) {
|
|
246
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_HOLDING_BPS) {
|
|
247
|
+
throw new TypeError("smart-restocking: holding_cost_bps must be a non-negative integer ≤ " + MAX_HOLDING_BPS);
|
|
248
|
+
}
|
|
249
|
+
return n;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function _orderingCost(n) {
|
|
253
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_ORDERING_COST_MINOR) {
|
|
254
|
+
throw new TypeError("smart-restocking: ordering_cost_minor must be a non-negative integer ≤ " + MAX_ORDERING_COST_MINOR);
|
|
255
|
+
}
|
|
256
|
+
return n;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function _serviceLevel(n) {
|
|
260
|
+
if (typeof n !== "number" || SERVICE_LEVELS.indexOf(n) === -1) {
|
|
261
|
+
throw new TypeError("smart-restocking: service_level must be one of " +
|
|
262
|
+
SERVICE_LEVELS.map(function (v) { return v.toFixed(2); }).join(", "));
|
|
263
|
+
}
|
|
264
|
+
return n;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function _horizonDays(n) {
|
|
268
|
+
if (n == null) return DEFAULT_HORIZON_DAYS;
|
|
269
|
+
if (!Number.isInteger(n) || n < MIN_HORIZON_DAYS || n > MAX_HORIZON_DAYS) {
|
|
270
|
+
throw new TypeError("smart-restocking: horizon_days must be an integer in " +
|
|
271
|
+
MIN_HORIZON_DAYS + ".." + MAX_HORIZON_DAYS);
|
|
272
|
+
}
|
|
273
|
+
return n;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function _epochMs(n, label) {
|
|
277
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
278
|
+
throw new TypeError("smart-restocking: " + label + " must be a non-negative integer (epoch ms)");
|
|
279
|
+
}
|
|
280
|
+
return n;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- row hydration ------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
function _shapePolicy(row) {
|
|
286
|
+
if (!row) return null;
|
|
287
|
+
return {
|
|
288
|
+
slug: row.slug,
|
|
289
|
+
holding_cost_bps: Number(row.holding_cost_bps),
|
|
290
|
+
ordering_cost_minor: Number(row.ordering_cost_minor),
|
|
291
|
+
default_service_level: Number(row.default_service_level),
|
|
292
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
293
|
+
created_at: Number(row.created_at),
|
|
294
|
+
updated_at: Number(row.updated_at),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function _shapeRec(row) {
|
|
299
|
+
if (!row) return null;
|
|
300
|
+
var reasoning;
|
|
301
|
+
try { reasoning = JSON.parse(row.reasoning_json || "{}"); }
|
|
302
|
+
catch (_e) { reasoning = {}; }
|
|
303
|
+
return {
|
|
304
|
+
id: row.id,
|
|
305
|
+
sku: row.sku,
|
|
306
|
+
location_code: row.location_code == null ? null : row.location_code,
|
|
307
|
+
recommended_qty: Number(row.recommended_qty),
|
|
308
|
+
eoq_qty: Number(row.eoq_qty),
|
|
309
|
+
safety_stock_qty: Number(row.safety_stock_qty),
|
|
310
|
+
reorder_point: Number(row.reorder_point),
|
|
311
|
+
cost_estimate_minor: Number(row.cost_estimate_minor),
|
|
312
|
+
currency: row.currency,
|
|
313
|
+
reasoning: reasoning,
|
|
314
|
+
computed_at: Number(row.computed_at),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---- math helpers -------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
// Weighted average unit cost across active FIFO layers. Returns
|
|
321
|
+
// { unit_cost_minor: number, currency: string } when at least one
|
|
322
|
+
// layer has positive remaining quantity; { unit_cost_minor: 0,
|
|
323
|
+
// currency: FALLBACK_CURRENCY } when no layers exist or all are
|
|
324
|
+
// exhausted. Mixed-currency layers throw — the operator's FX policy
|
|
325
|
+
// decides how to combine them and the primitive refuses to guess.
|
|
326
|
+
function _weightedAvgCost(layers) {
|
|
327
|
+
if (!layers || !layers.length) {
|
|
328
|
+
return { unit_cost_minor: 0, currency: FALLBACK_CURRENCY, has_data: false };
|
|
329
|
+
}
|
|
330
|
+
var totalQty = 0;
|
|
331
|
+
var totalCost = 0;
|
|
332
|
+
var currency = null;
|
|
333
|
+
for (var i = 0; i < layers.length; i += 1) {
|
|
334
|
+
var lay = layers[i];
|
|
335
|
+
var qty = Number(lay.quantity_remaining || lay.quantity || 0);
|
|
336
|
+
if (qty <= 0) continue;
|
|
337
|
+
if (currency == null) currency = lay.currency || FALLBACK_CURRENCY;
|
|
338
|
+
else if (lay.currency && lay.currency !== currency) {
|
|
339
|
+
throw new TypeError("smart-restocking: cost layers for sku span multiple currencies (" +
|
|
340
|
+
currency + ", " + lay.currency + ") — operator FX policy must reconcile before recommend");
|
|
341
|
+
}
|
|
342
|
+
totalQty += qty;
|
|
343
|
+
totalCost += qty * Number(lay.unit_cost_minor || 0);
|
|
344
|
+
}
|
|
345
|
+
if (totalQty <= 0) {
|
|
346
|
+
return { unit_cost_minor: 0, currency: FALLBACK_CURRENCY, has_data: false };
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
unit_cost_minor: Math.round(totalCost / totalQty),
|
|
350
|
+
currency: currency || FALLBACK_CURRENCY,
|
|
351
|
+
has_data: true,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// EOQ formula: sqrt((2 * annualDemand * orderingCost) / holdingCost).
|
|
356
|
+
// `annualDemand` in units; `orderingCostMinor` per-PO overhead in
|
|
357
|
+
// minor units; `holdingCostMinor` per-unit-per-year in minor units.
|
|
358
|
+
// Returns 0 when any input is non-positive (the cost-optimum
|
|
359
|
+
// collapses to "just buy the gap" — the safety stock layer + the
|
|
360
|
+
// reorder-point gap then carry the answer).
|
|
361
|
+
function _eoq(annualDemand, orderingCostMinor, holdingCostMinor) {
|
|
362
|
+
if (!(annualDemand > 0) || !(orderingCostMinor > 0) || !(holdingCostMinor > 0)) {
|
|
363
|
+
return 0;
|
|
364
|
+
}
|
|
365
|
+
var raw = Math.sqrt((2 * annualDemand * orderingCostMinor) / holdingCostMinor);
|
|
366
|
+
if (!isFinite(raw) || raw <= 0) return 0;
|
|
367
|
+
return Math.round(raw);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Daily-demand standard deviation derived from the forecast's
|
|
371
|
+
// confidence band. The forecast primitive ships predicted_units ±
|
|
372
|
+
// confidence band at the FORECAST_BAND_Z multiplier (1σ for SMA).
|
|
373
|
+
// Convert band width → 1σ, then scale to daily by dividing by
|
|
374
|
+
// horizon_days. Falls back to FALLBACK_CV * mean when no band exists.
|
|
375
|
+
function _dailyDemandStd(forecast, horizonDays) {
|
|
376
|
+
var pred = Number(forecast && forecast.predicted_units) || 0;
|
|
377
|
+
if (forecast && Number.isFinite(forecast.confidence_low) && Number.isFinite(forecast.confidence_high)) {
|
|
378
|
+
var bandHalf = (Number(forecast.confidence_high) - Number(forecast.confidence_low)) / 2;
|
|
379
|
+
if (bandHalf > 0) {
|
|
380
|
+
var oneSigma = bandHalf / FORECAST_BAND_Z;
|
|
381
|
+
return oneSigma / Math.max(horizonDays, 1);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
// Cold-start fallback: assume CV = FALLBACK_CV.
|
|
385
|
+
var dailyMean = pred / Math.max(horizonDays, 1);
|
|
386
|
+
return dailyMean * FALLBACK_CV;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Safety stock: z * sqrt(lead_time_days) * daily_demand_std.
|
|
390
|
+
function _safetyStock(serviceLevel, leadTimeDays, dailyDemandStd) {
|
|
391
|
+
var z = Z_SCORE[serviceLevel];
|
|
392
|
+
if (z == null) z = Z_SCORE[0.95];
|
|
393
|
+
if (!(leadTimeDays > 0) || !(dailyDemandStd > 0)) return 0;
|
|
394
|
+
var raw = z * Math.sqrt(leadTimeDays) * dailyDemandStd;
|
|
395
|
+
if (!isFinite(raw) || raw <= 0) return 0;
|
|
396
|
+
return Math.ceil(raw);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---- factory ------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
function create(opts) {
|
|
402
|
+
opts = opts || {};
|
|
403
|
+
|
|
404
|
+
var demandForecast = opts.demandForecast || null;
|
|
405
|
+
if (demandForecast != null && typeof demandForecast !== "object") {
|
|
406
|
+
throw new TypeError("smart-restocking.create: opts.demandForecast must be an object or null");
|
|
407
|
+
}
|
|
408
|
+
if (demandForecast != null && typeof demandForecast.forecastForSku !== "function") {
|
|
409
|
+
throw new TypeError("smart-restocking.create: opts.demandForecast must expose forecastForSku(...) when provided");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
var reorderThresholds = opts.reorderThresholds || null;
|
|
413
|
+
if (reorderThresholds != null && typeof reorderThresholds !== "object") {
|
|
414
|
+
throw new TypeError("smart-restocking.create: opts.reorderThresholds must be an object or null");
|
|
415
|
+
}
|
|
416
|
+
if (reorderThresholds != null && typeof reorderThresholds.evaluate !== "function") {
|
|
417
|
+
throw new TypeError("smart-restocking.create: opts.reorderThresholds must expose evaluate(...) when provided");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
var costLayers = opts.costLayers || null;
|
|
421
|
+
if (costLayers != null && typeof costLayers !== "object") {
|
|
422
|
+
throw new TypeError("smart-restocking.create: opts.costLayers must be an object or null");
|
|
423
|
+
}
|
|
424
|
+
if (costLayers != null && typeof costLayers.currentLayers !== "function") {
|
|
425
|
+
throw new TypeError("smart-restocking.create: opts.costLayers must expose currentLayers(...) when provided");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
var vendors = opts.vendors || null;
|
|
429
|
+
if (vendors != null && typeof vendors !== "object") {
|
|
430
|
+
throw new TypeError("smart-restocking.create: opts.vendors must be an object or null");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
var query = opts.query;
|
|
434
|
+
if (!query) {
|
|
435
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function _getPolicyRaw(slug) {
|
|
439
|
+
var r = await query(
|
|
440
|
+
"SELECT * FROM smart_restocking_policies WHERE slug = ?1",
|
|
441
|
+
[slug],
|
|
442
|
+
);
|
|
443
|
+
return r.rows[0] || null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function _getAssignmentRaw(sku) {
|
|
447
|
+
var r = await query(
|
|
448
|
+
"SELECT * FROM smart_restocking_policy_assignments WHERE sku = ?1",
|
|
449
|
+
[sku],
|
|
450
|
+
);
|
|
451
|
+
return r.rows[0] || null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Resolve the effective policy for a SKU. Falls back to DEFAULT_POLICY
|
|
455
|
+
// when no assignment exists OR the assigned policy is archived
|
|
456
|
+
// (the assignment outlives the policy, but the resolver picks the
|
|
457
|
+
// safer fallback when the binding is stale).
|
|
458
|
+
async function _resolvePolicy(sku) {
|
|
459
|
+
var assignment = await _getAssignmentRaw(sku);
|
|
460
|
+
if (!assignment) return { policy: DEFAULT_POLICY, source: "default" };
|
|
461
|
+
var row = await _getPolicyRaw(assignment.policy_slug);
|
|
462
|
+
if (!row || row.archived_at != null) {
|
|
463
|
+
return { policy: DEFAULT_POLICY, source: "default-archived-fallback" };
|
|
464
|
+
}
|
|
465
|
+
return { policy: _shapePolicy(row), source: "assigned" };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function _composeForecast(sku, locationCode, horizonDays) {
|
|
469
|
+
if (!demandForecast) {
|
|
470
|
+
return { forecast: null, reason: "forecast-dep-missing" };
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
var input = { sku: sku, horizon_days: horizonDays };
|
|
474
|
+
if (locationCode != null) input.location_code = locationCode;
|
|
475
|
+
var f = await demandForecast.forecastForSku(input);
|
|
476
|
+
return { forecast: f, reason: null };
|
|
477
|
+
} catch (e) {
|
|
478
|
+
return { forecast: null, reason: "forecast-failed:" + ((e && e.message) || "unknown") };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function _composeThreshold(sku, locationCode) {
|
|
483
|
+
if (!reorderThresholds) {
|
|
484
|
+
return { evalRow: null, reason: "threshold-dep-missing" };
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
var input = { sku: sku };
|
|
488
|
+
if (locationCode != null) input.location_code = locationCode;
|
|
489
|
+
var ev = await reorderThresholds.evaluate(input);
|
|
490
|
+
return { evalRow: ev, reason: null };
|
|
491
|
+
} catch (e) {
|
|
492
|
+
return { evalRow: null, reason: "threshold-failed:" + ((e && e.message) || "unknown") };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function _composeCost(sku) {
|
|
497
|
+
if (!costLayers) {
|
|
498
|
+
return { cost: { unit_cost_minor: 0, currency: FALLBACK_CURRENCY, has_data: false }, reason: "cost-dep-missing" };
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
var layers = await costLayers.currentLayers({ sku: sku });
|
|
502
|
+
return { cost: _weightedAvgCost(layers), reason: null };
|
|
503
|
+
} catch (e) {
|
|
504
|
+
return {
|
|
505
|
+
cost: { unit_cost_minor: 0, currency: FALLBACK_CURRENCY, has_data: false },
|
|
506
|
+
reason: "cost-failed:" + ((e && e.message) || "unknown"),
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Resolve a lead-time-days signal. Priority:
|
|
512
|
+
// 1. reorderThresholds.evaluate's lead_time_days
|
|
513
|
+
// 2. vendors.getVendor's lead_time_days (when supplied by the
|
|
514
|
+
// composed vendor primitive)
|
|
515
|
+
// 3. FALLBACK_LEAD_TIME_DAYS
|
|
516
|
+
async function _resolveLeadTime(evalRow) {
|
|
517
|
+
if (evalRow && Number.isInteger(evalRow.lead_time_days) && evalRow.lead_time_days >= 0) {
|
|
518
|
+
return { days: evalRow.lead_time_days, source: "threshold" };
|
|
519
|
+
}
|
|
520
|
+
// The threshold primitive carries the vendor_slug on its evalRow
|
|
521
|
+
// when bound; fall back to vendor-level lead-time when the
|
|
522
|
+
// composed vendor handle supplies it.
|
|
523
|
+
if (vendors && evalRow && evalRow.vendor_slug && typeof vendors.getVendor === "function") {
|
|
524
|
+
try {
|
|
525
|
+
var v = await vendors.getVendor(evalRow.vendor_slug);
|
|
526
|
+
if (v && Number.isInteger(v.lead_time_days) && v.lead_time_days >= 0) {
|
|
527
|
+
return { days: v.lead_time_days, source: "vendor" };
|
|
528
|
+
}
|
|
529
|
+
} catch (_e) {
|
|
530
|
+
// Fall through to the default — vendor read failures are
|
|
531
|
+
// not fatal to a restocking recommendation.
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return { days: FALLBACK_LEAD_TIME_DAYS, source: "default" };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Core recommendation engine — pure given inputs; the surrounding
|
|
538
|
+
// verbs handle composition + persistence.
|
|
539
|
+
function _recommend(inputs) {
|
|
540
|
+
var policy = inputs.policy;
|
|
541
|
+
var serviceLevel = inputs.serviceLevel;
|
|
542
|
+
var horizonDays = inputs.horizonDays;
|
|
543
|
+
var forecast = inputs.forecast; // may be null
|
|
544
|
+
var leadTimeDays = inputs.leadTimeDays;
|
|
545
|
+
var currentStock = inputs.currentStock; // may be null
|
|
546
|
+
var unitCostMinor = inputs.unitCostMinor;
|
|
547
|
+
var currency = inputs.currency;
|
|
548
|
+
|
|
549
|
+
var predictedUnits = forecast ? Number(forecast.predicted_units) || 0 : 0;
|
|
550
|
+
var dailyDemand = predictedUnits / Math.max(horizonDays, 1);
|
|
551
|
+
var annualDemand = dailyDemand * 365;
|
|
552
|
+
|
|
553
|
+
// Holding cost per unit per year (minor units). bps is annual.
|
|
554
|
+
var holdingPerUnitYear = (unitCostMinor * policy.holding_cost_bps) / 10000;
|
|
555
|
+
|
|
556
|
+
var eoqQty = _eoq(annualDemand, policy.ordering_cost_minor, holdingPerUnitYear);
|
|
557
|
+
|
|
558
|
+
var dailyStd = _dailyDemandStd(forecast, horizonDays);
|
|
559
|
+
var safetyStockQty = _safetyStock(serviceLevel, leadTimeDays, dailyStd);
|
|
560
|
+
|
|
561
|
+
var recommendedQty = eoqQty + safetyStockQty;
|
|
562
|
+
|
|
563
|
+
// When EOQ collapsed to 0 (no holding-cost data) but the
|
|
564
|
+
// operator still needs to refill the gap, fall back to the
|
|
565
|
+
// velocity-times-horizon estimate so the recommendation isn't
|
|
566
|
+
// zero for a SKU that's selling.
|
|
567
|
+
if (eoqQty === 0 && predictedUnits > 0) {
|
|
568
|
+
recommendedQty = Math.max(recommendedQty, Math.ceil(predictedUnits));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
var reorderPoint = Math.ceil(dailyDemand * leadTimeDays) + safetyStockQty;
|
|
572
|
+
|
|
573
|
+
var daysOfSupply = null;
|
|
574
|
+
if (currentStock != null && dailyDemand > 0) {
|
|
575
|
+
daysOfSupply = Math.floor(Number(currentStock) / dailyDemand);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
var unitsCost = recommendedQty * unitCostMinor;
|
|
579
|
+
var costEstimateMinor = unitsCost + policy.ordering_cost_minor;
|
|
580
|
+
|
|
581
|
+
return {
|
|
582
|
+
recommended_qty: recommendedQty,
|
|
583
|
+
eoq_qty: eoqQty,
|
|
584
|
+
safety_stock_qty: safetyStockQty,
|
|
585
|
+
reorder_point: reorderPoint,
|
|
586
|
+
days_of_supply: daysOfSupply,
|
|
587
|
+
cost_estimate_minor: costEstimateMinor,
|
|
588
|
+
currency: currency,
|
|
589
|
+
_daily_demand: dailyDemand,
|
|
590
|
+
_annual_demand: annualDemand,
|
|
591
|
+
_daily_std: dailyStd,
|
|
592
|
+
_holding_per_unit_year: holdingPerUnitYear,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
return {
|
|
597
|
+
SERVICE_LEVELS: SERVICE_LEVELS.slice(),
|
|
598
|
+
Z_SCORE: Object.assign({}, Z_SCORE),
|
|
599
|
+
DEFAULT_POLICY: Object.assign({}, DEFAULT_POLICY),
|
|
600
|
+
FALLBACK_LEAD_TIME_DAYS: FALLBACK_LEAD_TIME_DAYS,
|
|
601
|
+
FALLBACK_CURRENCY: FALLBACK_CURRENCY,
|
|
602
|
+
DEFAULT_HORIZON_DAYS: DEFAULT_HORIZON_DAYS,
|
|
603
|
+
|
|
604
|
+
// Register / patch a smart-restocking policy. Re-defining the
|
|
605
|
+
// same slug patches every field in place (operators surfacing
|
|
606
|
+
// this through an admin UI write the same slug repeatedly on
|
|
607
|
+
// each "save").
|
|
608
|
+
definePolicy: async function (input) {
|
|
609
|
+
if (!input || typeof input !== "object") {
|
|
610
|
+
throw new TypeError("smart-restocking.definePolicy: input object required");
|
|
611
|
+
}
|
|
612
|
+
var slug = _slug(input.slug, "slug");
|
|
613
|
+
var holdingBps = _holdingBps(input.holding_cost_bps);
|
|
614
|
+
var orderingCost = _orderingCost(input.ordering_cost_minor);
|
|
615
|
+
var defaultService = _serviceLevel(input.default_service_level);
|
|
616
|
+
var ts = _now();
|
|
617
|
+
|
|
618
|
+
var existing = await _getPolicyRaw(slug);
|
|
619
|
+
if (existing) {
|
|
620
|
+
await query(
|
|
621
|
+
"UPDATE smart_restocking_policies SET holding_cost_bps = ?1, " +
|
|
622
|
+
"ordering_cost_minor = ?2, default_service_level = ?3, " +
|
|
623
|
+
"archived_at = NULL, updated_at = ?4 WHERE slug = ?5",
|
|
624
|
+
[holdingBps, orderingCost, defaultService, ts, slug],
|
|
625
|
+
);
|
|
626
|
+
return _shapePolicy(await _getPolicyRaw(slug));
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
await query(
|
|
630
|
+
"INSERT INTO smart_restocking_policies " +
|
|
631
|
+
"(slug, holding_cost_bps, ordering_cost_minor, default_service_level, " +
|
|
632
|
+
" archived_at, created_at, updated_at) " +
|
|
633
|
+
"VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5)",
|
|
634
|
+
[slug, holdingBps, orderingCost, defaultService, ts],
|
|
635
|
+
);
|
|
636
|
+
return _shapePolicy(await _getPolicyRaw(slug));
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
getPolicy: async function (slug) {
|
|
640
|
+
_slug(slug, "slug");
|
|
641
|
+
return _shapePolicy(await _getPolicyRaw(slug));
|
|
642
|
+
},
|
|
643
|
+
|
|
644
|
+
listPolicies: async function (listOpts) {
|
|
645
|
+
listOpts = listOpts || {};
|
|
646
|
+
var where = listOpts.include_archived ? "" : "WHERE archived_at IS NULL";
|
|
647
|
+
var r = await query(
|
|
648
|
+
"SELECT * FROM smart_restocking_policies " + where + " ORDER BY slug ASC",
|
|
649
|
+
[],
|
|
650
|
+
);
|
|
651
|
+
return r.rows.map(_shapePolicy);
|
|
652
|
+
},
|
|
653
|
+
|
|
654
|
+
archivePolicy: async function (slug) {
|
|
655
|
+
_slug(slug, "slug");
|
|
656
|
+
var existing = await _getPolicyRaw(slug);
|
|
657
|
+
if (!existing) {
|
|
658
|
+
throw new TypeError("smart-restocking.archivePolicy: policy " +
|
|
659
|
+
JSON.stringify(slug) + " not found");
|
|
660
|
+
}
|
|
661
|
+
if (existing.archived_at != null) return _shapePolicy(existing);
|
|
662
|
+
var ts = _now();
|
|
663
|
+
await query(
|
|
664
|
+
"UPDATE smart_restocking_policies SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
|
|
665
|
+
[ts, slug],
|
|
666
|
+
);
|
|
667
|
+
return _shapePolicy(await _getPolicyRaw(slug));
|
|
668
|
+
},
|
|
669
|
+
|
|
670
|
+
applyPolicy: async function (input) {
|
|
671
|
+
if (!input || typeof input !== "object") {
|
|
672
|
+
throw new TypeError("smart-restocking.applyPolicy: input object required");
|
|
673
|
+
}
|
|
674
|
+
var slug = _slug(input.slug, "slug");
|
|
675
|
+
var sku = _sku(input.sku);
|
|
676
|
+
var existingPolicy = await _getPolicyRaw(slug);
|
|
677
|
+
if (!existingPolicy) {
|
|
678
|
+
throw new TypeError("smart-restocking.applyPolicy: policy " +
|
|
679
|
+
JSON.stringify(slug) + " not found");
|
|
680
|
+
}
|
|
681
|
+
if (existingPolicy.archived_at != null) {
|
|
682
|
+
throw new TypeError("smart-restocking.applyPolicy: policy " +
|
|
683
|
+
JSON.stringify(slug) + " is archived — assign to an active policy");
|
|
684
|
+
}
|
|
685
|
+
var ts = _now();
|
|
686
|
+
var existingAssignment = await _getAssignmentRaw(sku);
|
|
687
|
+
if (existingAssignment) {
|
|
688
|
+
await query(
|
|
689
|
+
"UPDATE smart_restocking_policy_assignments SET policy_slug = ?1, assigned_at = ?2 WHERE sku = ?3",
|
|
690
|
+
[slug, ts, sku],
|
|
691
|
+
);
|
|
692
|
+
} else {
|
|
693
|
+
await query(
|
|
694
|
+
"INSERT INTO smart_restocking_policy_assignments (sku, policy_slug, assigned_at) VALUES (?1, ?2, ?3)",
|
|
695
|
+
[sku, slug, ts],
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
return {
|
|
699
|
+
sku: sku,
|
|
700
|
+
policy_slug: slug,
|
|
701
|
+
assigned_at: ts,
|
|
702
|
+
};
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
unassignPolicy: async function (input) {
|
|
706
|
+
if (!input || typeof input !== "object") {
|
|
707
|
+
throw new TypeError("smart-restocking.unassignPolicy: input object required");
|
|
708
|
+
}
|
|
709
|
+
var sku = _sku(input.sku);
|
|
710
|
+
var r = await query(
|
|
711
|
+
"DELETE FROM smart_restocking_policy_assignments WHERE sku = ?1",
|
|
712
|
+
[sku],
|
|
713
|
+
);
|
|
714
|
+
return { sku: sku, removed: Number(r.rowCount) > 0 };
|
|
715
|
+
},
|
|
716
|
+
|
|
717
|
+
policyForSku: async function (sku) {
|
|
718
|
+
_sku(sku);
|
|
719
|
+
var assignment = await _getAssignmentRaw(sku);
|
|
720
|
+
if (!assignment) return null;
|
|
721
|
+
var policy = _shapePolicy(await _getPolicyRaw(assignment.policy_slug));
|
|
722
|
+
return {
|
|
723
|
+
sku: sku,
|
|
724
|
+
policy_slug: assignment.policy_slug,
|
|
725
|
+
assigned_at: Number(assignment.assigned_at),
|
|
726
|
+
policy: policy,
|
|
727
|
+
};
|
|
728
|
+
},
|
|
729
|
+
|
|
730
|
+
// The core verb. Composes forecast + threshold + cost-layer
|
|
731
|
+
// signals, runs the EOQ + safety-stock math, persists a
|
|
732
|
+
// recommendation row, returns the result.
|
|
733
|
+
recommendOrderQty: async function (input) {
|
|
734
|
+
if (!input || typeof input !== "object") {
|
|
735
|
+
throw new TypeError("smart-restocking.recommendOrderQty: input object required");
|
|
736
|
+
}
|
|
737
|
+
var sku = _sku(input.sku);
|
|
738
|
+
var locationCode = _locationOrNull(input.location_code);
|
|
739
|
+
var horizonDays = _horizonDays(input.horizon_days);
|
|
740
|
+
|
|
741
|
+
var resolved = await _resolvePolicy(sku);
|
|
742
|
+
var policy = resolved.policy;
|
|
743
|
+
|
|
744
|
+
var serviceLevel = input.service_level == null
|
|
745
|
+
? policy.default_service_level
|
|
746
|
+
: _serviceLevel(input.service_level);
|
|
747
|
+
|
|
748
|
+
var forecastResult = await _composeForecast(sku, locationCode, horizonDays);
|
|
749
|
+
var thresholdResult = await _composeThreshold(sku, locationCode);
|
|
750
|
+
var costResult = await _composeCost(sku);
|
|
751
|
+
var leadTimeResult = await _resolveLeadTime(thresholdResult.evalRow);
|
|
752
|
+
|
|
753
|
+
var currentStock = thresholdResult.evalRow
|
|
754
|
+
? Number(thresholdResult.evalRow.current_stock)
|
|
755
|
+
: null;
|
|
756
|
+
|
|
757
|
+
var rec = _recommend({
|
|
758
|
+
policy: policy,
|
|
759
|
+
serviceLevel: serviceLevel,
|
|
760
|
+
horizonDays: horizonDays,
|
|
761
|
+
forecast: forecastResult.forecast,
|
|
762
|
+
leadTimeDays: leadTimeResult.days,
|
|
763
|
+
currentStock: currentStock,
|
|
764
|
+
unitCostMinor: costResult.cost.unit_cost_minor,
|
|
765
|
+
currency: costResult.cost.currency,
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
var tags = [];
|
|
769
|
+
if (forecastResult.reason) tags.push(forecastResult.reason);
|
|
770
|
+
if (thresholdResult.reason) tags.push(thresholdResult.reason);
|
|
771
|
+
if (costResult.reason) tags.push(costResult.reason);
|
|
772
|
+
if (!costResult.cost.has_data) tags.push("no-cost-data");
|
|
773
|
+
|
|
774
|
+
var reasoning = {
|
|
775
|
+
policy_slug: policy.slug,
|
|
776
|
+
policy_source: resolved.source,
|
|
777
|
+
service_level: serviceLevel,
|
|
778
|
+
z_score: Z_SCORE[serviceLevel] || null,
|
|
779
|
+
horizon_days: horizonDays,
|
|
780
|
+
predicted_units: forecastResult.forecast ? Number(forecastResult.forecast.predicted_units) || 0 : null,
|
|
781
|
+
confidence_low: forecastResult.forecast ? forecastResult.forecast.confidence_low : null,
|
|
782
|
+
confidence_high: forecastResult.forecast ? forecastResult.forecast.confidence_high : null,
|
|
783
|
+
daily_demand: rec._daily_demand,
|
|
784
|
+
daily_demand_std: rec._daily_std,
|
|
785
|
+
annual_demand: rec._annual_demand,
|
|
786
|
+
unit_cost_minor: costResult.cost.unit_cost_minor,
|
|
787
|
+
holding_per_unit_year: rec._holding_per_unit_year,
|
|
788
|
+
ordering_cost_minor: policy.ordering_cost_minor,
|
|
789
|
+
holding_cost_bps: policy.holding_cost_bps,
|
|
790
|
+
lead_time_days: leadTimeResult.days,
|
|
791
|
+
lead_time_source: leadTimeResult.source,
|
|
792
|
+
current_stock: currentStock,
|
|
793
|
+
tags: tags,
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
var id = _b().uuid.v7();
|
|
797
|
+
var ts = _now();
|
|
798
|
+
await query(
|
|
799
|
+
"INSERT INTO smart_restocking_recommendations " +
|
|
800
|
+
"(id, sku, location_code, recommended_qty, eoq_qty, safety_stock_qty, " +
|
|
801
|
+
" reorder_point, cost_estimate_minor, currency, reasoning_json, computed_at) " +
|
|
802
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
|
803
|
+
[id, sku, locationCode, rec.recommended_qty, rec.eoq_qty, rec.safety_stock_qty,
|
|
804
|
+
rec.reorder_point, rec.cost_estimate_minor, rec.currency,
|
|
805
|
+
JSON.stringify(reasoning), ts],
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
return {
|
|
809
|
+
recommended_qty: rec.recommended_qty,
|
|
810
|
+
eoq_qty: rec.eoq_qty,
|
|
811
|
+
safety_stock_qty: rec.safety_stock_qty,
|
|
812
|
+
reorder_point: rec.reorder_point,
|
|
813
|
+
days_of_supply: rec.days_of_supply,
|
|
814
|
+
cost_estimate_minor: rec.cost_estimate_minor,
|
|
815
|
+
currency: rec.currency,
|
|
816
|
+
reasoning: reasoning,
|
|
817
|
+
};
|
|
818
|
+
},
|
|
819
|
+
|
|
820
|
+
bulkRecommend: async function (input) {
|
|
821
|
+
if (!input || typeof input !== "object") {
|
|
822
|
+
throw new TypeError("smart-restocking.bulkRecommend: input object required");
|
|
823
|
+
}
|
|
824
|
+
if (!Array.isArray(input.skus)) {
|
|
825
|
+
throw new TypeError("smart-restocking.bulkRecommend: skus must be an array");
|
|
826
|
+
}
|
|
827
|
+
if (input.skus.length === 0) {
|
|
828
|
+
throw new TypeError("smart-restocking.bulkRecommend: skus must contain at least one entry");
|
|
829
|
+
}
|
|
830
|
+
if (input.skus.length > MAX_BULK_SKUS) {
|
|
831
|
+
throw new TypeError("smart-restocking.bulkRecommend: skus may not exceed " + MAX_BULK_SKUS + " entries");
|
|
832
|
+
}
|
|
833
|
+
// Service level + horizon validated once up front when present.
|
|
834
|
+
var serviceLevelOpt = input.service_level == null ? null : _serviceLevel(input.service_level);
|
|
835
|
+
var horizonOpt = input.horizon_days == null ? null : _horizonDays(input.horizon_days);
|
|
836
|
+
var locationCode = _locationOrNull(input.location_code);
|
|
837
|
+
|
|
838
|
+
var out = [];
|
|
839
|
+
for (var i = 0; i < input.skus.length; i += 1) {
|
|
840
|
+
var sku = input.skus[i];
|
|
841
|
+
try {
|
|
842
|
+
_sku(sku, "skus[" + i + "]");
|
|
843
|
+
} catch (e) {
|
|
844
|
+
out.push({ sku: sku, error: (e && e.message) || "invalid sku" });
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
try {
|
|
848
|
+
var perInput = { sku: sku };
|
|
849
|
+
if (locationCode != null) perInput.location_code = locationCode;
|
|
850
|
+
if (serviceLevelOpt != null) perInput.service_level = serviceLevelOpt;
|
|
851
|
+
if (horizonOpt != null) perInput.horizon_days = horizonOpt;
|
|
852
|
+
var rec = await this.recommendOrderQty(perInput);
|
|
853
|
+
out.push({
|
|
854
|
+
sku: sku,
|
|
855
|
+
recommended_qty: rec.recommended_qty,
|
|
856
|
+
eoq_qty: rec.eoq_qty,
|
|
857
|
+
safety_stock_qty: rec.safety_stock_qty,
|
|
858
|
+
reorder_point: rec.reorder_point,
|
|
859
|
+
days_of_supply: rec.days_of_supply,
|
|
860
|
+
cost_estimate_minor: rec.cost_estimate_minor,
|
|
861
|
+
currency: rec.currency,
|
|
862
|
+
reasoning: rec.reasoning,
|
|
863
|
+
});
|
|
864
|
+
} catch (e) {
|
|
865
|
+
out.push({ sku: sku, error: (e && e.message) || "recommend failed" });
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return out;
|
|
869
|
+
},
|
|
870
|
+
|
|
871
|
+
metricsForSku: async function (input) {
|
|
872
|
+
if (!input || typeof input !== "object") {
|
|
873
|
+
throw new TypeError("smart-restocking.metricsForSku: input object required");
|
|
874
|
+
}
|
|
875
|
+
var sku = _sku(input.sku);
|
|
876
|
+
var from = _epochMs(input.from, "from");
|
|
877
|
+
var to = _epochMs(input.to, "to");
|
|
878
|
+
if (to < from) {
|
|
879
|
+
throw new TypeError("smart-restocking.metricsForSku: to (" + to + ") must be ≥ from (" + from + ")");
|
|
880
|
+
}
|
|
881
|
+
var r = await query(
|
|
882
|
+
"SELECT * FROM smart_restocking_recommendations " +
|
|
883
|
+
"WHERE sku = ?1 AND computed_at >= ?2 AND computed_at <= ?3 " +
|
|
884
|
+
"ORDER BY computed_at DESC, id DESC",
|
|
885
|
+
[sku, from, to],
|
|
886
|
+
);
|
|
887
|
+
var rows = r.rows.map(_shapeRec);
|
|
888
|
+
var count = rows.length;
|
|
889
|
+
if (count === 0) {
|
|
890
|
+
return {
|
|
891
|
+
sku: sku,
|
|
892
|
+
count: 0,
|
|
893
|
+
avg_recommended_qty: 0,
|
|
894
|
+
avg_eoq_qty: 0,
|
|
895
|
+
avg_safety_stock_qty: 0,
|
|
896
|
+
total_cost_estimate_minor: 0,
|
|
897
|
+
currency: null,
|
|
898
|
+
rows: [],
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
var sumRec = 0;
|
|
902
|
+
var sumEoq = 0;
|
|
903
|
+
var sumSafety = 0;
|
|
904
|
+
var sumCost = 0;
|
|
905
|
+
var currency = rows[0].currency;
|
|
906
|
+
var mixed = false;
|
|
907
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
908
|
+
var row = rows[i];
|
|
909
|
+
sumRec += row.recommended_qty;
|
|
910
|
+
sumEoq += row.eoq_qty;
|
|
911
|
+
sumSafety += row.safety_stock_qty;
|
|
912
|
+
if (row.currency !== currency) mixed = true;
|
|
913
|
+
if (!mixed) sumCost += row.cost_estimate_minor;
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
sku: sku,
|
|
917
|
+
count: count,
|
|
918
|
+
avg_recommended_qty: sumRec / count,
|
|
919
|
+
avg_eoq_qty: sumEoq / count,
|
|
920
|
+
avg_safety_stock_qty: sumSafety / count,
|
|
921
|
+
total_cost_estimate_minor: mixed ? null : sumCost,
|
|
922
|
+
currency: mixed ? null : currency,
|
|
923
|
+
rows: rows,
|
|
924
|
+
};
|
|
925
|
+
},
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Smoke-callable run() — exercises the factory shape + the
|
|
930
|
+
// recommendOrderQty math against an in-memory query stub + stubbed
|
|
931
|
+
// composed primitives. The full coverage (composition against the
|
|
932
|
+
// real demandForecast / reorderThresholds / costLayers + EOQ math
|
|
933
|
+
// across service levels) lives in the layer-1 state test where the
|
|
934
|
+
// sqlite migration + dep graph is wired end-to-end.
|
|
935
|
+
async function run() {
|
|
936
|
+
var policies = {};
|
|
937
|
+
var assignments = {};
|
|
938
|
+
var recs = [];
|
|
939
|
+
var q = async function (sql, params) {
|
|
940
|
+
params = params || [];
|
|
941
|
+
var verb = sql.replace(/^\s+/, "").split(/\s+/)[0].toUpperCase();
|
|
942
|
+
if (verb === "SELECT" && /FROM smart_restocking_policies/.test(sql) && /slug\s*=\s*\?1/.test(sql)) {
|
|
943
|
+
var p = policies[params[0]];
|
|
944
|
+
return { rows: p ? [p] : [], rowCount: p ? 1 : 0 };
|
|
945
|
+
}
|
|
946
|
+
if (verb === "SELECT" && /FROM smart_restocking_policies/.test(sql)) {
|
|
947
|
+
var out = [];
|
|
948
|
+
var keys = Object.keys(policies);
|
|
949
|
+
for (var k = 0; k < keys.length; k += 1) {
|
|
950
|
+
if (policies[keys[k]].archived_at == null) out.push(policies[keys[k]]);
|
|
951
|
+
}
|
|
952
|
+
return { rows: out, rowCount: out.length };
|
|
953
|
+
}
|
|
954
|
+
if (verb === "SELECT" && /FROM smart_restocking_policy_assignments/.test(sql)) {
|
|
955
|
+
var a = assignments[params[0]];
|
|
956
|
+
return { rows: a ? [a] : [], rowCount: a ? 1 : 0 };
|
|
957
|
+
}
|
|
958
|
+
if (verb === "SELECT" && /FROM smart_restocking_recommendations/.test(sql)) {
|
|
959
|
+
return { rows: recs.slice(), rowCount: recs.length };
|
|
960
|
+
}
|
|
961
|
+
if (verb === "INSERT" && /smart_restocking_policies/.test(sql)) {
|
|
962
|
+
policies[params[0]] = {
|
|
963
|
+
slug: params[0],
|
|
964
|
+
holding_cost_bps: params[1],
|
|
965
|
+
ordering_cost_minor: params[2],
|
|
966
|
+
default_service_level: params[3],
|
|
967
|
+
archived_at: null,
|
|
968
|
+
created_at: params[4],
|
|
969
|
+
updated_at: params[4],
|
|
970
|
+
};
|
|
971
|
+
return { rows: [], rowCount: 1 };
|
|
972
|
+
}
|
|
973
|
+
if (verb === "UPDATE" && /smart_restocking_policies/.test(sql)) {
|
|
974
|
+
var slug = params[params.length - 1];
|
|
975
|
+
var ex = policies[slug];
|
|
976
|
+
if (ex) ex.updated_at = params[params.length - 2];
|
|
977
|
+
return { rows: [], rowCount: ex ? 1 : 0 };
|
|
978
|
+
}
|
|
979
|
+
if (verb === "INSERT" && /smart_restocking_policy_assignments/.test(sql)) {
|
|
980
|
+
assignments[params[0]] = {
|
|
981
|
+
sku: params[0],
|
|
982
|
+
policy_slug: params[1],
|
|
983
|
+
assigned_at: params[2],
|
|
984
|
+
};
|
|
985
|
+
return { rows: [], rowCount: 1 };
|
|
986
|
+
}
|
|
987
|
+
if (verb === "UPDATE" && /smart_restocking_policy_assignments/.test(sql)) {
|
|
988
|
+
var sku = params[params.length - 1];
|
|
989
|
+
var aex = assignments[sku];
|
|
990
|
+
if (aex) {
|
|
991
|
+
aex.policy_slug = params[0];
|
|
992
|
+
aex.assigned_at = params[1];
|
|
993
|
+
}
|
|
994
|
+
return { rows: [], rowCount: aex ? 1 : 0 };
|
|
995
|
+
}
|
|
996
|
+
if (verb === "DELETE" && /smart_restocking_policy_assignments/.test(sql)) {
|
|
997
|
+
var existed = assignments[params[0]] != null;
|
|
998
|
+
delete assignments[params[0]];
|
|
999
|
+
return { rows: [], rowCount: existed ? 1 : 0 };
|
|
1000
|
+
}
|
|
1001
|
+
if (verb === "INSERT" && /smart_restocking_recommendations/.test(sql)) {
|
|
1002
|
+
recs.push({
|
|
1003
|
+
id: params[0],
|
|
1004
|
+
sku: params[1],
|
|
1005
|
+
location_code: params[2],
|
|
1006
|
+
recommended_qty: params[3],
|
|
1007
|
+
eoq_qty: params[4],
|
|
1008
|
+
safety_stock_qty: params[5],
|
|
1009
|
+
reorder_point: params[6],
|
|
1010
|
+
cost_estimate_minor: params[7],
|
|
1011
|
+
currency: params[8],
|
|
1012
|
+
reasoning_json: params[9],
|
|
1013
|
+
computed_at: params[10],
|
|
1014
|
+
});
|
|
1015
|
+
return { rows: [], rowCount: 1 };
|
|
1016
|
+
}
|
|
1017
|
+
return { rows: [], rowCount: 0 };
|
|
1018
|
+
};
|
|
1019
|
+
|
|
1020
|
+
var sr = create({ query: q });
|
|
1021
|
+
await sr.definePolicy({
|
|
1022
|
+
slug: "smoke-policy",
|
|
1023
|
+
holding_cost_bps: 2500,
|
|
1024
|
+
ordering_cost_minor: 5000,
|
|
1025
|
+
default_service_level: 0.95,
|
|
1026
|
+
});
|
|
1027
|
+
var p = await sr.getPolicy("smoke-policy");
|
|
1028
|
+
if (!p || p.slug !== "smoke-policy") {
|
|
1029
|
+
throw new Error("smart-restocking.run: smoke definePolicy round-trip failed");
|
|
1030
|
+
}
|
|
1031
|
+
await sr.applyPolicy({ slug: "smoke-policy", sku: "SMOKE-1" });
|
|
1032
|
+
var rec = await sr.recommendOrderQty({ sku: "SMOKE-1" });
|
|
1033
|
+
if (!rec || !Number.isInteger(rec.recommended_qty)) {
|
|
1034
|
+
throw new Error("smart-restocking.run: smoke recommendOrderQty round-trip failed");
|
|
1035
|
+
}
|
|
1036
|
+
return { ok: true };
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
module.exports = {
|
|
1040
|
+
create: create,
|
|
1041
|
+
run: run,
|
|
1042
|
+
SERVICE_LEVELS: SERVICE_LEVELS,
|
|
1043
|
+
Z_SCORE: Z_SCORE,
|
|
1044
|
+
DEFAULT_POLICY: DEFAULT_POLICY,
|
|
1045
|
+
FALLBACK_LEAD_TIME_DAYS: FALLBACK_LEAD_TIME_DAYS,
|
|
1046
|
+
FALLBACK_CURRENCY: FALLBACK_CURRENCY,
|
|
1047
|
+
DEFAULT_HORIZON_DAYS: DEFAULT_HORIZON_DAYS,
|
|
1048
|
+
};
|