@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,1182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.shrinkageReport
|
|
4
|
+
* @title Shrinkage report — loss-prevention dashboard aggregations
|
|
5
|
+
* over inventory_writeoffs
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Operators record stock that left the building without a sale
|
|
9
|
+
* through the `inventoryWriteoffs` primitive (eight enumerated
|
|
10
|
+
* reasons: damaged / lost / shrinkage / expired / recall / sample /
|
|
11
|
+
* quality_control / theft). The dashboard surface is this primitive
|
|
12
|
+
* — read-only aggregations over those rows, sliced by reason /
|
|
13
|
+
* period / location / sku / category, and a `flagAnomalies` detector
|
|
14
|
+
* that surfaces locations whose loss in the period sits more than
|
|
15
|
+
* `threshold_multiplier` standard deviations above the cross-
|
|
16
|
+
* location mean. Every result reports units (always available) and
|
|
17
|
+
* cost-impact (when costLayers was wired at write-off time so the
|
|
18
|
+
* `cost_impact_minor` column on the source row carries a number).
|
|
19
|
+
*
|
|
20
|
+
* Surface (every method async; every method honors a cache opt-in):
|
|
21
|
+
*
|
|
22
|
+
* report({ from, to, location_code?, sku?, reason? })
|
|
23
|
+
* Top-level rollup. Returns:
|
|
24
|
+
* {
|
|
25
|
+
* from, to,
|
|
26
|
+
* total_units,
|
|
27
|
+
* total_cost_impact_minor, // null when nothing carries cost
|
|
28
|
+
* currency, // null when no cost rows
|
|
29
|
+
* period_shrinkage_rate_bps, // total_units * 10000 / aggregate-on-hand
|
|
30
|
+
* by_reason: [{ reason, units, cost_impact_minor, currency, share_bps }],
|
|
31
|
+
* by_location: [{ location_code, units, cost_impact_minor, currency, share_bps }],
|
|
32
|
+
* by_sku: [{ sku, units, cost_impact_minor, currency, share_bps }],
|
|
33
|
+
* by_category: [{ category, units, cost_impact_minor, currency, share_bps }]
|
|
34
|
+
* }
|
|
35
|
+
* Filters compose with AND. Reversed rows are excluded
|
|
36
|
+
* (operator un-did them; they didn't actually cost anything).
|
|
37
|
+
* `period_shrinkage_rate_bps` is integer basis points
|
|
38
|
+
* (0..10000+ — the rate can exceed 100% if more units were
|
|
39
|
+
* written off than the on-hand baseline, e.g. catastrophic
|
|
40
|
+
* theft event). The operator-facing dashboard renders
|
|
41
|
+
* `bps / 100` as a percentage.
|
|
42
|
+
*
|
|
43
|
+
* topLossLocations({ from, to, limit? })
|
|
44
|
+
* Top-N locations ranked by cost-impact desc, units desc as the
|
|
45
|
+
* tie-break. `limit` defaults to 10, capped at 100. Returns
|
|
46
|
+
* `[{ location_code, units, cost_impact_minor, currency,
|
|
47
|
+
* reason_top, reason_top_units }]` — `reason_top` is the most
|
|
48
|
+
* frequent reason on that location in the window (which bucket
|
|
49
|
+
* drives the loss). Locations with zero loss in the window are
|
|
50
|
+
* excluded.
|
|
51
|
+
*
|
|
52
|
+
* topShrinkageSkus({ from, to, limit? })
|
|
53
|
+
* Top-N SKUs ranked by units desc, cost-impact desc as the
|
|
54
|
+
* tie-break. Same shape as topLossLocations but indexed by sku
|
|
55
|
+
* + carries a `location_top` field naming the location with the
|
|
56
|
+
* most writeoffs for that SKU. The `units` primary sort matches
|
|
57
|
+
* loss-prevention intuition — operators care about "which SKUs
|
|
58
|
+
* walk most often", not "which SKUs are most expensive when
|
|
59
|
+
* they walk".
|
|
60
|
+
*
|
|
61
|
+
* categoryComparison({ from, to })
|
|
62
|
+
* Side-by-side comparison of the four reason-categories the
|
|
63
|
+
* primitive defines (operational / perishable / external /
|
|
64
|
+
* deliberate). Returns `[{ category, units, cost_impact_minor,
|
|
65
|
+
* currency, share_bps, top_reason, top_reason_units }]`,
|
|
66
|
+
* ordered by units desc. Reason → category mapping:
|
|
67
|
+
*
|
|
68
|
+
* operational — damaged, quality_control
|
|
69
|
+
* perishable — expired, recall
|
|
70
|
+
* external — sample
|
|
71
|
+
* deliberate — lost, shrinkage, theft
|
|
72
|
+
*
|
|
73
|
+
* monthlyTrend({ from, to })
|
|
74
|
+
* Per-month rollup over the window. Returns `[{ bucket_start,
|
|
75
|
+
* units, cost_impact_minor, currency }]` — bucket_start is the
|
|
76
|
+
* first day of the month (UTC, YYYY-MM-DD). Windows up to
|
|
77
|
+
* 2 years are supported; longer windows refused (the trend
|
|
78
|
+
* line on the dashboard caps at 24 ticks for legibility).
|
|
79
|
+
*
|
|
80
|
+
* reasonBreakdownPie({ from, to })
|
|
81
|
+
* Pie-chart-shaped breakdown by reason. Returns `[{ reason,
|
|
82
|
+
* units, share_bps, color_hint }]` — `color_hint` is a stable
|
|
83
|
+
* per-reason CSS color string so the dashboard renders the
|
|
84
|
+
* same wedge color across refreshes without a separate
|
|
85
|
+
* theme-table join.
|
|
86
|
+
*
|
|
87
|
+
* flagAnomalies({ from, to, threshold_multiplier?, min_units? })
|
|
88
|
+
* Statistical-outlier detection over per-location loss. The
|
|
89
|
+
* primitive computes the mean + sample-stddev of units across
|
|
90
|
+
* every location with at least `min_units` write-offs in the
|
|
91
|
+
* window (default 1), then flags every location whose units
|
|
92
|
+
* exceed `mean + threshold_multiplier * stddev` (default 2.0 —
|
|
93
|
+
* roughly the upper 5% in a normal distribution). Returns
|
|
94
|
+
* `[{ location_code, units, cost_impact_minor, currency,
|
|
95
|
+
* z_score, mean, stddev, threshold }]` ordered by z_score
|
|
96
|
+
* desc. When fewer than 2 locations have writeoffs in the
|
|
97
|
+
* window, returns `[]` (stddev undefined; no anomaly call
|
|
98
|
+
* possible). Anomaly call is one of the loudest loss-
|
|
99
|
+
* prevention signals in retail — surfaced separately from the
|
|
100
|
+
* top-N so the dashboard can color it red without forcing the
|
|
101
|
+
* operator to read a leaderboard for clues.
|
|
102
|
+
*
|
|
103
|
+
* purgeExpired(nowTs?)
|
|
104
|
+
* Operator-cron sweep: deletes cache rows whose
|
|
105
|
+
* `computed_at + scope-TTL <= now`. Returns `{ deleted }`.
|
|
106
|
+
*
|
|
107
|
+
* Composition (compose-only — never hand-rolls primitives that
|
|
108
|
+
* blamejs already ships):
|
|
109
|
+
* - b.crypto.namespaceHash — params_hash on cache rows.
|
|
110
|
+
* - b.uuid.v7 — cache-row ids.
|
|
111
|
+
*
|
|
112
|
+
* Three-tier input validation (use the discipline; don't write the
|
|
113
|
+
* labels): every public verb is a config-time entry point — bad
|
|
114
|
+
* shape throws. No drop-silent hot-path sinks: aggregations are
|
|
115
|
+
* operator dashboards, not request-path observability.
|
|
116
|
+
*
|
|
117
|
+
* Strict-monotonic clock: per-factory `_monotonicTs()` ensures two
|
|
118
|
+
* cache rows written in the same wall-clock millisecond don't tie
|
|
119
|
+
* on `computed_at` (matters when the dashboard does fan-out reads
|
|
120
|
+
* inside the same event-loop tick).
|
|
121
|
+
*
|
|
122
|
+
* No-MVP scope: every report is wired through the cache layer; the
|
|
123
|
+
* anomaly detector handles the single-location degenerate case
|
|
124
|
+
* explicitly; cost-impact attribution is currency-coherent across
|
|
125
|
+
* each rollup (mixed-currency periods refuse to roll up a single
|
|
126
|
+
* cost_impact total). `inventoryWriteoffs?` injection is optional
|
|
127
|
+
* only because the primitive reads from the table directly via the
|
|
128
|
+
* query handle — the injection is reserved for future cross-call
|
|
129
|
+
* composition (writeoffs.subscribe-to-invalidation).
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
var bShop;
|
|
133
|
+
function _b() {
|
|
134
|
+
if (!bShop) bShop = require("./index");
|
|
135
|
+
return bShop.framework;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- constants ----------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
var DAY_MS = 24 * 60 * 60 * 1000;
|
|
141
|
+
var TWO_YEARS_MS = 730 * DAY_MS;
|
|
142
|
+
var MAX_LIMIT = 100;
|
|
143
|
+
var DEFAULT_LIMIT = 10;
|
|
144
|
+
|
|
145
|
+
var CACHE_NAMESPACE = "shrinkage-report-cache";
|
|
146
|
+
|
|
147
|
+
// Cache TTLs by scope. Operator can override per-call via
|
|
148
|
+
// `cache_ttl_ms`; these are the safe defaults — short enough that a
|
|
149
|
+
// new write-off shows up on a refresh, long enough that an operator
|
|
150
|
+
// hammering the dashboard doesn't re-scan the writeoffs table on
|
|
151
|
+
// every keystroke. Anomaly detection caches the longest because the
|
|
152
|
+
// computation is the heaviest and the operator response time on a
|
|
153
|
+
// red-flag alert is minutes-not-seconds.
|
|
154
|
+
var DEFAULT_CACHE_TTL_MS = Object.freeze({
|
|
155
|
+
report: 60 * 1000,
|
|
156
|
+
top_locations: 5 * 60 * 1000,
|
|
157
|
+
top_skus: 5 * 60 * 1000,
|
|
158
|
+
category_comparison: 5 * 60 * 1000,
|
|
159
|
+
monthly_trend: 10 * 60 * 1000,
|
|
160
|
+
reason_pie: 5 * 60 * 1000,
|
|
161
|
+
anomalies: 15 * 60 * 1000,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Reason-category mapping. Frozen at module load so the operator-
|
|
165
|
+
// facing categoryComparison surface returns a stable shape and the
|
|
166
|
+
// reason_pie color-hint table can mirror the same partition.
|
|
167
|
+
var REASON_CATEGORY = Object.freeze({
|
|
168
|
+
damaged: "operational",
|
|
169
|
+
quality_control: "operational",
|
|
170
|
+
expired: "perishable",
|
|
171
|
+
recall: "perishable",
|
|
172
|
+
sample: "external",
|
|
173
|
+
lost: "deliberate",
|
|
174
|
+
shrinkage: "deliberate",
|
|
175
|
+
theft: "deliberate",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
var REASON_ENUM = Object.freeze([
|
|
179
|
+
"damaged", "lost", "shrinkage", "expired",
|
|
180
|
+
"recall", "sample", "quality_control", "theft",
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
// Stable CSS color hints for the pie-chart wedges. Frozen so a
|
|
184
|
+
// dashboard refresh after a vendor update doesn't shuffle wedge
|
|
185
|
+
// colors (operators build visual muscle memory on the chart).
|
|
186
|
+
var REASON_COLOR = Object.freeze({
|
|
187
|
+
damaged: "#d97706", // amber-600
|
|
188
|
+
lost: "#7c3aed", // violet-600
|
|
189
|
+
shrinkage: "#dc2626", // red-600
|
|
190
|
+
expired: "#65a30d", // lime-600
|
|
191
|
+
recall: "#ea580c", // orange-600
|
|
192
|
+
sample: "#0891b2", // cyan-600
|
|
193
|
+
quality_control: "#0284c7", // sky-600
|
|
194
|
+
theft: "#9f1239", // rose-800
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Validation regex — matches the inventory-writeoffs primitive so
|
|
198
|
+
// rollup filter inputs honor the same shape gates as the producer.
|
|
199
|
+
var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
200
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
201
|
+
|
|
202
|
+
// ---- validators ---------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
function _epochMs(n, label) {
|
|
205
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
206
|
+
throw new TypeError("shrinkage-report: " + label + " must be a non-negative integer (epoch ms)");
|
|
207
|
+
}
|
|
208
|
+
return n;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function _resolveWindow(opts, maxSpanMs) {
|
|
212
|
+
if (!opts || typeof opts !== "object") {
|
|
213
|
+
throw new TypeError("shrinkage-report: input object required (from + to epoch-ms)");
|
|
214
|
+
}
|
|
215
|
+
if (opts.from == null || opts.to == null) {
|
|
216
|
+
throw new TypeError("shrinkage-report: from and to are required (epoch-ms)");
|
|
217
|
+
}
|
|
218
|
+
_epochMs(opts.from, "from");
|
|
219
|
+
_epochMs(opts.to, "to");
|
|
220
|
+
if (opts.from >= opts.to) {
|
|
221
|
+
throw new TypeError("shrinkage-report: from must be strictly less than to");
|
|
222
|
+
}
|
|
223
|
+
var span = opts.to - opts.from;
|
|
224
|
+
var bound = maxSpanMs == null ? TWO_YEARS_MS : maxSpanMs;
|
|
225
|
+
if (span > bound) {
|
|
226
|
+
throw new TypeError("shrinkage-report: window (to - from) must be ≤ " + bound + "ms");
|
|
227
|
+
}
|
|
228
|
+
return { from: opts.from, to: opts.to };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function _limit(n, label) {
|
|
232
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_LIMIT) {
|
|
233
|
+
throw new TypeError("shrinkage-report: " + (label || "limit") +
|
|
234
|
+
" must be an integer in [1, " + MAX_LIMIT + "]");
|
|
235
|
+
}
|
|
236
|
+
return n;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function _optReason(v) {
|
|
240
|
+
if (v == null) return null;
|
|
241
|
+
if (typeof v !== "string" || REASON_ENUM.indexOf(v) === -1) {
|
|
242
|
+
throw new TypeError("shrinkage-report: reason must be one of " +
|
|
243
|
+
REASON_ENUM.join(", ") + ", got " + JSON.stringify(v));
|
|
244
|
+
}
|
|
245
|
+
return v;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function _optCode(v, label) {
|
|
249
|
+
if (v == null) return null;
|
|
250
|
+
if (typeof v !== "string" || !CODE_RE.test(v)) {
|
|
251
|
+
throw new TypeError("shrinkage-report: " + (label || "location_code") +
|
|
252
|
+
" must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
|
|
253
|
+
}
|
|
254
|
+
return v;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function _optSku(v) {
|
|
258
|
+
if (v == null) return null;
|
|
259
|
+
if (typeof v !== "string" || !SKU_RE.test(v)) {
|
|
260
|
+
throw new TypeError("shrinkage-report: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
261
|
+
}
|
|
262
|
+
return v;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function _finitePositive(n, label) {
|
|
266
|
+
if (typeof n !== "number" || !isFinite(n) || n <= 0) {
|
|
267
|
+
throw new TypeError("shrinkage-report: " + label + " must be a positive finite number");
|
|
268
|
+
}
|
|
269
|
+
return n;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _nonNegInt(n, label) {
|
|
273
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
274
|
+
throw new TypeError("shrinkage-report: " + label + " must be a non-negative integer");
|
|
275
|
+
}
|
|
276
|
+
return n;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Canonical JSON for cache key — sorted keys, stable across runs so
|
|
280
|
+
// the same input hashes the same way every time.
|
|
281
|
+
function _canonicalJSON(value) {
|
|
282
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
283
|
+
if (Array.isArray(value)) {
|
|
284
|
+
return "[" + value.map(_canonicalJSON).join(",") + "]";
|
|
285
|
+
}
|
|
286
|
+
var keys = Object.keys(value).sort();
|
|
287
|
+
var parts = [];
|
|
288
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
289
|
+
parts.push(JSON.stringify(keys[i]) + ":" + _canonicalJSON(value[keys[i]]));
|
|
290
|
+
}
|
|
291
|
+
return "{" + parts.join(",") + "}";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Currency-coherence reducer over a per-(group, currency) rowset.
|
|
295
|
+
// Returns the single coherent currency across the rowset or null
|
|
296
|
+
// when none of the rows carry a currency (no costLayers wiring at
|
|
297
|
+
// write-off time). Throws when two distinct currencies appear — a
|
|
298
|
+
// rolled-up cost_impact across mixed currencies is meaningless
|
|
299
|
+
// without an FX conversion this primitive doesn't own.
|
|
300
|
+
function _coherentCurrency(rows, label) {
|
|
301
|
+
var currency = null;
|
|
302
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
303
|
+
var c = rows[i].currency;
|
|
304
|
+
if (c == null) continue;
|
|
305
|
+
if (currency == null) currency = c;
|
|
306
|
+
else if (c !== currency) {
|
|
307
|
+
throw new TypeError("shrinkage-report: " + label + " spans multiple currencies (" +
|
|
308
|
+
currency + ", " + c + ") — reconcile before reporting");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return currency;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Share-of-total in integer basis points (0..10000). Returns 0 when
|
|
315
|
+
// total is zero (avoids NaN; the dashboard reads 0 as "no share").
|
|
316
|
+
function _shareBps(part, total) {
|
|
317
|
+
if (total <= 0) return 0;
|
|
318
|
+
return Math.round((part / total) * 10000);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---- factory ------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
function create(opts) {
|
|
324
|
+
opts = opts || {};
|
|
325
|
+
var query = opts.query;
|
|
326
|
+
if (!query) {
|
|
327
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
328
|
+
}
|
|
329
|
+
// The `inventoryWriteoffs?` / `costLayers?` / `order?` peer
|
|
330
|
+
// injections are reserved for future cross-call composition (e.g.
|
|
331
|
+
// write-off events triggering targeted cache invalidation). The
|
|
332
|
+
// primitive reads from the canonical tables directly via `query`
|
|
333
|
+
// today, so storing the references is enough — using them happens
|
|
334
|
+
// when the corresponding cross-primitive verb lands.
|
|
335
|
+
var writeoffsPeer = opts.inventoryWriteoffs == null ? null : opts.inventoryWriteoffs;
|
|
336
|
+
var costLayersPeer = opts.costLayers == null ? null : opts.costLayers;
|
|
337
|
+
var orderPeer = opts.order == null ? null : opts.order;
|
|
338
|
+
|
|
339
|
+
// Per-factory monotonic clock for cache `computed_at` stamps. Two
|
|
340
|
+
// cache refreshes against the same scope in the same wall-clock
|
|
341
|
+
// millisecond would otherwise tie on computed_at and confuse the
|
|
342
|
+
// freshness check. Forward-leap when wall outpaces the counter;
|
|
343
|
+
// otherwise bump by 1ms so the sequence stays strictly increasing
|
|
344
|
+
// per primitive instance.
|
|
345
|
+
var _lastTs = 0;
|
|
346
|
+
function _monotonicTs() {
|
|
347
|
+
var wall = Date.now();
|
|
348
|
+
if (wall > _lastTs) _lastTs = wall;
|
|
349
|
+
else _lastTs += 1;
|
|
350
|
+
return _lastTs;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---- cache helpers --------------------------------------------------
|
|
354
|
+
|
|
355
|
+
function _scopeValue(scopeKey, params) {
|
|
356
|
+
var payload = scopeKey + "::" + _canonicalJSON(params);
|
|
357
|
+
return _b().crypto.namespaceHash(CACHE_NAMESPACE, payload);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function _cacheLookup(scopeKey, scopeValue, periodFrom, periodTo, ttlMs, nowTs) {
|
|
361
|
+
var r = await query(
|
|
362
|
+
"SELECT breakdown_json, computed_at FROM shrinkage_report_cache " +
|
|
363
|
+
" WHERE scope_key = ?1 AND scope_value = ?2 " +
|
|
364
|
+
" AND period_from = ?3 AND period_to = ?4",
|
|
365
|
+
[scopeKey, scopeValue, periodFrom, periodTo],
|
|
366
|
+
);
|
|
367
|
+
if (!r.rows.length) return null;
|
|
368
|
+
var computedAt = Number(r.rows[0].computed_at) || 0;
|
|
369
|
+
if (computedAt + ttlMs <= nowTs) return null;
|
|
370
|
+
try { return JSON.parse(r.rows[0].breakdown_json); }
|
|
371
|
+
catch (_e) { return null; } // drop-silent — bad JSON falls through to recompute
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function _cacheStore(scopeKey, scopeValue, periodFrom, periodTo, result) {
|
|
375
|
+
var id = _b().uuid.v7();
|
|
376
|
+
var totalUnits = Number.isInteger(result && result.total_units) ? result.total_units : 0;
|
|
377
|
+
var totalCost = (result && result.total_cost_impact_minor != null)
|
|
378
|
+
? Number(result.total_cost_impact_minor)
|
|
379
|
+
: null;
|
|
380
|
+
var nowTs = _monotonicTs();
|
|
381
|
+
// REPLACE on the unique (scope_key, scope_value, period_from,
|
|
382
|
+
// period_to) — keeps the index clean without leaving stale rows.
|
|
383
|
+
await query(
|
|
384
|
+
"DELETE FROM shrinkage_report_cache " +
|
|
385
|
+
" WHERE scope_key = ?1 AND scope_value = ?2 " +
|
|
386
|
+
" AND period_from = ?3 AND period_to = ?4",
|
|
387
|
+
[scopeKey, scopeValue, periodFrom, periodTo],
|
|
388
|
+
);
|
|
389
|
+
await query(
|
|
390
|
+
"INSERT INTO shrinkage_report_cache " +
|
|
391
|
+
"(id, scope_key, scope_value, period_from, period_to, " +
|
|
392
|
+
" total_units, total_cost_impact_minor, breakdown_json, computed_at) " +
|
|
393
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
394
|
+
[id, scopeKey, scopeValue, periodFrom, periodTo,
|
|
395
|
+
totalUnits, totalCost, JSON.stringify(result), nowTs],
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Wrap a recompute function with the cache. `enabled` defaults
|
|
400
|
+
// off; opt-in per call via `opts.cache === true` so test fixtures
|
|
401
|
+
// see fresh data and dashboards opt in explicitly.
|
|
402
|
+
async function _withCache(scopeKey, params, cacheOpts, periodFrom, periodTo, recompute) {
|
|
403
|
+
if (!cacheOpts || cacheOpts.cache !== true) return recompute();
|
|
404
|
+
var ttl = cacheOpts.cache_ttl_ms == null
|
|
405
|
+
? DEFAULT_CACHE_TTL_MS[scopeKey]
|
|
406
|
+
: cacheOpts.cache_ttl_ms;
|
|
407
|
+
if (!Number.isInteger(ttl) || ttl < 0) {
|
|
408
|
+
throw new TypeError("shrinkage-report: cache_ttl_ms must be a non-negative integer");
|
|
409
|
+
}
|
|
410
|
+
var scopeValue = _scopeValue(scopeKey, params);
|
|
411
|
+
var nowTs = Date.now();
|
|
412
|
+
var hit = await _cacheLookup(scopeKey, scopeValue, periodFrom, periodTo, ttl, nowTs);
|
|
413
|
+
if (hit) return hit;
|
|
414
|
+
var fresh = await recompute();
|
|
415
|
+
if (ttl > 0) await _cacheStore(scopeKey, scopeValue, periodFrom, periodTo, fresh);
|
|
416
|
+
return fresh;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---- shared rollup helpers ------------------------------------------
|
|
420
|
+
|
|
421
|
+
// Base WHERE clause for the writeoffs scan: period + status +
|
|
422
|
+
// optional filters. Returns `{ clauses, params, nextIdx }`. Every
|
|
423
|
+
// caller appends additional clauses to the same array if needed.
|
|
424
|
+
function _baseScanWhere(window, filters) {
|
|
425
|
+
var clauses = ["occurred_at >= ?1", "occurred_at < ?2", "status = 'recorded'"];
|
|
426
|
+
var params = [window.from, window.to];
|
|
427
|
+
var idx = 3;
|
|
428
|
+
if (filters) {
|
|
429
|
+
if (filters.location_code != null) {
|
|
430
|
+
clauses.push("location_code = ?" + idx);
|
|
431
|
+
params.push(filters.location_code);
|
|
432
|
+
idx += 1;
|
|
433
|
+
}
|
|
434
|
+
if (filters.sku != null) {
|
|
435
|
+
clauses.push("sku = ?" + idx);
|
|
436
|
+
params.push(filters.sku);
|
|
437
|
+
idx += 1;
|
|
438
|
+
}
|
|
439
|
+
if (filters.reason != null) {
|
|
440
|
+
clauses.push("reason = ?" + idx);
|
|
441
|
+
params.push(filters.reason);
|
|
442
|
+
idx += 1;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return { clauses: clauses, params: params, nextIdx: idx };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function _groupedScan(groupCol, window, filters) {
|
|
449
|
+
var base = _baseScanWhere(window, filters);
|
|
450
|
+
// GROUP BY both the dimension AND currency so the currency-
|
|
451
|
+
// coherence check has the raw split available; the caller folds
|
|
452
|
+
// multi-currency rows on the same key into one (sum units; sum
|
|
453
|
+
// cost only when currencies match).
|
|
454
|
+
var sql =
|
|
455
|
+
"SELECT " + groupCol + " AS k, currency, " +
|
|
456
|
+
" SUM(quantity) AS units, " +
|
|
457
|
+
" SUM(cost_impact_minor) AS cost_impact_minor " +
|
|
458
|
+
" FROM inventory_writeoffs " +
|
|
459
|
+
" WHERE " + base.clauses.join(" AND ") +
|
|
460
|
+
" GROUP BY " + groupCol + ", currency";
|
|
461
|
+
var r = await query(sql, base.params);
|
|
462
|
+
return r.rows.map(function (row) {
|
|
463
|
+
return {
|
|
464
|
+
k: row.k,
|
|
465
|
+
currency: row.currency,
|
|
466
|
+
units: Number(row.units) || 0,
|
|
467
|
+
cost_impact_minor: row.cost_impact_minor == null ? null : Number(row.cost_impact_minor),
|
|
468
|
+
};
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Fold per-(dimension, currency) rows into per-dimension records.
|
|
473
|
+
// Sums units across currencies, sums cost_impact within a single
|
|
474
|
+
// coherent currency (multi-currency cost on the same dimension
|
|
475
|
+
// refuses with a clear operator-facing error — same posture as
|
|
476
|
+
// inventoryWriteoffs.costImpactForPeriod). Records with no cost
|
|
477
|
+
// rows surface `cost_impact_minor: null` (the costLayers wiring
|
|
478
|
+
// wasn't on when the write-off landed; the operator sees "units
|
|
479
|
+
// known, value unknown").
|
|
480
|
+
function _foldByKey(rows, label) {
|
|
481
|
+
var bucket = Object.create(null);
|
|
482
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
483
|
+
var row = rows[i];
|
|
484
|
+
if (row.k == null) continue;
|
|
485
|
+
var key = String(row.k);
|
|
486
|
+
var slot = bucket[key];
|
|
487
|
+
if (!slot) {
|
|
488
|
+
slot = bucket[key] = { units: 0, cost_impact_minor: null, currency: null };
|
|
489
|
+
}
|
|
490
|
+
slot.units += row.units;
|
|
491
|
+
if (row.cost_impact_minor != null) {
|
|
492
|
+
if (slot.currency != null && slot.currency !== row.currency) {
|
|
493
|
+
throw new TypeError("shrinkage-report: " + label + " '" + key +
|
|
494
|
+
"' spans multiple currencies (" + slot.currency + ", " + row.currency +
|
|
495
|
+
") — reconcile before reporting");
|
|
496
|
+
}
|
|
497
|
+
slot.currency = row.currency;
|
|
498
|
+
slot.cost_impact_minor = (slot.cost_impact_minor || 0) + row.cost_impact_minor;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
var keys = Object.keys(bucket).sort();
|
|
502
|
+
var out = [];
|
|
503
|
+
for (var j = 0; j < keys.length; j += 1) {
|
|
504
|
+
var b = bucket[keys[j]];
|
|
505
|
+
out.push({
|
|
506
|
+
key: keys[j],
|
|
507
|
+
units: b.units,
|
|
508
|
+
cost_impact_minor: b.cost_impact_minor,
|
|
509
|
+
currency: b.currency,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// SUM(quantity) + SUM(cost_impact_minor) across the whole window
|
|
516
|
+
// post-filter, by currency. Used both as the headline number on
|
|
517
|
+
// `report` and as the denominator on share_bps calculations.
|
|
518
|
+
async function _totals(window, filters) {
|
|
519
|
+
var base = _baseScanWhere(window, filters);
|
|
520
|
+
var sql =
|
|
521
|
+
"SELECT currency, " +
|
|
522
|
+
" SUM(quantity) AS units, " +
|
|
523
|
+
" SUM(cost_impact_minor) AS cost_impact_minor " +
|
|
524
|
+
" FROM inventory_writeoffs " +
|
|
525
|
+
" WHERE " + base.clauses.join(" AND ") +
|
|
526
|
+
" GROUP BY currency";
|
|
527
|
+
var r = await query(sql, base.params);
|
|
528
|
+
return r.rows.map(function (row) {
|
|
529
|
+
return {
|
|
530
|
+
currency: row.currency,
|
|
531
|
+
units: Number(row.units) || 0,
|
|
532
|
+
cost_impact_minor: row.cost_impact_minor == null ? null : Number(row.cost_impact_minor),
|
|
533
|
+
};
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// On-hand baseline for shrinkage-rate computation. Sums
|
|
538
|
+
// `quantity` across the inventory_stock table at the moment of
|
|
539
|
+
// the report — the rate compares this period's loss to the
|
|
540
|
+
// current shelf-snapshot. When the inventory_stock table is
|
|
541
|
+
// absent (no inventoryLocations migration loaded in a test fixture
|
|
542
|
+
// that only cares about writeoffs), we fall back to 0 which makes
|
|
543
|
+
// the rate undefined (returned as null) rather than crashing the
|
|
544
|
+
// whole report.
|
|
545
|
+
async function _onHandBaseline(filters) {
|
|
546
|
+
var clauses = [];
|
|
547
|
+
var params = [];
|
|
548
|
+
var idx = 1;
|
|
549
|
+
if (filters && filters.location_code != null) {
|
|
550
|
+
clauses.push("location_code = ?" + idx);
|
|
551
|
+
params.push(filters.location_code);
|
|
552
|
+
idx += 1;
|
|
553
|
+
}
|
|
554
|
+
if (filters && filters.sku != null) {
|
|
555
|
+
clauses.push("sku = ?" + idx);
|
|
556
|
+
params.push(filters.sku);
|
|
557
|
+
idx += 1;
|
|
558
|
+
}
|
|
559
|
+
var where = clauses.length ? " WHERE " + clauses.join(" AND ") : "";
|
|
560
|
+
try {
|
|
561
|
+
var r = await query(
|
|
562
|
+
"SELECT COALESCE(SUM(quantity), 0) AS total FROM inventory_stock" + where,
|
|
563
|
+
params,
|
|
564
|
+
);
|
|
565
|
+
return Number(r.rows[0] && r.rows[0].total) || 0;
|
|
566
|
+
} catch (_e) {
|
|
567
|
+
// The inventory_stock table isn't loaded into this fixture —
|
|
568
|
+
// every shrinkage-rate computation in this run returns null
|
|
569
|
+
// rather than guessing a baseline. Operator-facing: the report
|
|
570
|
+
// row carries `period_shrinkage_rate_bps: null` so the
|
|
571
|
+
// dashboard renders "—" instead of "0%".
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
DAY_MS: DAY_MS,
|
|
578
|
+
TWO_YEARS_MS: TWO_YEARS_MS,
|
|
579
|
+
DEFAULT_CACHE_TTL_MS: DEFAULT_CACHE_TTL_MS,
|
|
580
|
+
REASON_CATEGORY: REASON_CATEGORY,
|
|
581
|
+
REASON_COLOR: REASON_COLOR,
|
|
582
|
+
|
|
583
|
+
// Top-level rollup. Returns the headline numbers plus four
|
|
584
|
+
// dimension breakdowns (reason / location / sku / category).
|
|
585
|
+
// Every breakdown carries share_bps so the dashboard can render
|
|
586
|
+
// "share of total" without re-summing on the client.
|
|
587
|
+
report: async function (input) {
|
|
588
|
+
var window = _resolveWindow(input);
|
|
589
|
+
var locF = _optCode(input && input.location_code, "location_code");
|
|
590
|
+
var skuF = _optSku(input && input.sku);
|
|
591
|
+
var reasF = _optReason(input && input.reason);
|
|
592
|
+
var filters = { location_code: locF, sku: skuF, reason: reasF };
|
|
593
|
+
var cacheOpts = input && input.cache === true ? { cache: true, cache_ttl_ms: input.cache_ttl_ms } : null;
|
|
594
|
+
var params = {
|
|
595
|
+
from: window.from, to: window.to,
|
|
596
|
+
location_code: locF, sku: skuF, reason: reasF,
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
return _withCache("report", params, cacheOpts, window.from, window.to, async function () {
|
|
600
|
+
// Headline totals.
|
|
601
|
+
var totalRows = await _totals(window, filters);
|
|
602
|
+
var totalUnits = 0;
|
|
603
|
+
var totalCost = null;
|
|
604
|
+
var totalCurrency = null;
|
|
605
|
+
for (var i = 0; i < totalRows.length; i += 1) {
|
|
606
|
+
totalUnits += totalRows[i].units;
|
|
607
|
+
if (totalRows[i].cost_impact_minor != null) {
|
|
608
|
+
if (totalCurrency != null && totalCurrency !== totalRows[i].currency) {
|
|
609
|
+
throw new TypeError("shrinkage-report.report: window spans multiple currencies (" +
|
|
610
|
+
totalCurrency + ", " + totalRows[i].currency + ") — reconcile before reporting");
|
|
611
|
+
}
|
|
612
|
+
totalCurrency = totalRows[i].currency;
|
|
613
|
+
totalCost = (totalCost || 0) + totalRows[i].cost_impact_minor;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// On-hand baseline for the shrinkage rate.
|
|
618
|
+
var baseline = await _onHandBaseline(filters);
|
|
619
|
+
var rateBps = null;
|
|
620
|
+
if (baseline != null && baseline > 0) {
|
|
621
|
+
rateBps = Math.round((totalUnits / baseline) * 10000);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Four dimension breakdowns. Each one shares the same
|
|
625
|
+
// grouped-scan + fold + share_bps pipeline.
|
|
626
|
+
var reasonRows = _foldByKey(await _groupedScan("reason", window, filters), "reason");
|
|
627
|
+
var locationRows = _foldByKey(await _groupedScan("location_code", window, filters), "location");
|
|
628
|
+
var skuRows = _foldByKey(await _groupedScan("sku", window, filters), "sku");
|
|
629
|
+
|
|
630
|
+
// Category rollup — fold the reason rows by the static
|
|
631
|
+
// reason → category mapping. Cost-impact currency-coherence
|
|
632
|
+
// applies the same way as the other dimensions.
|
|
633
|
+
var catBucket = Object.create(null);
|
|
634
|
+
for (var r = 0; r < reasonRows.length; r += 1) {
|
|
635
|
+
var rr = reasonRows[r];
|
|
636
|
+
var category = REASON_CATEGORY[rr.key] || "unknown";
|
|
637
|
+
var slot = catBucket[category];
|
|
638
|
+
if (!slot) slot = catBucket[category] = { units: 0, cost_impact_minor: null, currency: null };
|
|
639
|
+
slot.units += rr.units;
|
|
640
|
+
if (rr.cost_impact_minor != null) {
|
|
641
|
+
if (slot.currency != null && slot.currency !== rr.currency) {
|
|
642
|
+
throw new TypeError("shrinkage-report.report: category '" + category +
|
|
643
|
+
"' spans multiple currencies (" + slot.currency + ", " + rr.currency +
|
|
644
|
+
") — reconcile before reporting");
|
|
645
|
+
}
|
|
646
|
+
slot.currency = rr.currency;
|
|
647
|
+
slot.cost_impact_minor = (slot.cost_impact_minor || 0) + rr.cost_impact_minor;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
var catKeys = Object.keys(catBucket).sort();
|
|
651
|
+
var byCategory = catKeys.map(function (k) {
|
|
652
|
+
return {
|
|
653
|
+
category: k,
|
|
654
|
+
units: catBucket[k].units,
|
|
655
|
+
cost_impact_minor: catBucket[k].cost_impact_minor,
|
|
656
|
+
currency: catBucket[k].currency,
|
|
657
|
+
share_bps: _shareBps(catBucket[k].units, totalUnits),
|
|
658
|
+
};
|
|
659
|
+
});
|
|
660
|
+
byCategory.sort(function (a, b) { return b.units - a.units || (a.category < b.category ? -1 : 1); });
|
|
661
|
+
|
|
662
|
+
var byReason = reasonRows.map(function (rr) {
|
|
663
|
+
return {
|
|
664
|
+
reason: rr.key,
|
|
665
|
+
units: rr.units,
|
|
666
|
+
cost_impact_minor: rr.cost_impact_minor,
|
|
667
|
+
currency: rr.currency,
|
|
668
|
+
share_bps: _shareBps(rr.units, totalUnits),
|
|
669
|
+
};
|
|
670
|
+
});
|
|
671
|
+
byReason.sort(function (a, b) { return b.units - a.units || (a.reason < b.reason ? -1 : 1); });
|
|
672
|
+
|
|
673
|
+
var byLocation = locationRows.map(function (rr) {
|
|
674
|
+
return {
|
|
675
|
+
location_code: rr.key,
|
|
676
|
+
units: rr.units,
|
|
677
|
+
cost_impact_minor: rr.cost_impact_minor,
|
|
678
|
+
currency: rr.currency,
|
|
679
|
+
share_bps: _shareBps(rr.units, totalUnits),
|
|
680
|
+
};
|
|
681
|
+
});
|
|
682
|
+
byLocation.sort(function (a, b) { return b.units - a.units || (a.location_code < b.location_code ? -1 : 1); });
|
|
683
|
+
|
|
684
|
+
var bySku = skuRows.map(function (rr) {
|
|
685
|
+
return {
|
|
686
|
+
sku: rr.key,
|
|
687
|
+
units: rr.units,
|
|
688
|
+
cost_impact_minor: rr.cost_impact_minor,
|
|
689
|
+
currency: rr.currency,
|
|
690
|
+
share_bps: _shareBps(rr.units, totalUnits),
|
|
691
|
+
};
|
|
692
|
+
});
|
|
693
|
+
bySku.sort(function (a, b) { return b.units - a.units || (a.sku < b.sku ? -1 : 1); });
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
from: window.from,
|
|
697
|
+
to: window.to,
|
|
698
|
+
total_units: totalUnits,
|
|
699
|
+
total_cost_impact_minor: totalCost,
|
|
700
|
+
currency: totalCurrency,
|
|
701
|
+
period_shrinkage_rate_bps: rateBps,
|
|
702
|
+
by_reason: byReason,
|
|
703
|
+
by_location: byLocation,
|
|
704
|
+
by_sku: bySku,
|
|
705
|
+
by_category: byCategory,
|
|
706
|
+
};
|
|
707
|
+
});
|
|
708
|
+
},
|
|
709
|
+
|
|
710
|
+
// Top-N locations ranked by cost-impact desc, units desc tie-
|
|
711
|
+
// break. Returns the `reason_top` for each location so the
|
|
712
|
+
// operator sees which reason drives the loss at a glance.
|
|
713
|
+
topLossLocations: async function (input) {
|
|
714
|
+
var window = _resolveWindow(input);
|
|
715
|
+
var rawLimit = (input && input.limit) == null ? DEFAULT_LIMIT : input.limit;
|
|
716
|
+
_limit(rawLimit, "limit");
|
|
717
|
+
var cacheOpts = input && input.cache === true ? { cache: true, cache_ttl_ms: input.cache_ttl_ms } : null;
|
|
718
|
+
var params = { from: window.from, to: window.to, limit: rawLimit };
|
|
719
|
+
|
|
720
|
+
return _withCache("top_locations", params, cacheOpts, window.from, window.to, async function () {
|
|
721
|
+
// Per-(location, reason) scan — fold to per-location with
|
|
722
|
+
// a top-reason picked from the most-units reason on that
|
|
723
|
+
// location. Excludes rows with null location_code (global
|
|
724
|
+
// catalog-level write-offs have no per-location attribution
|
|
725
|
+
// to rank).
|
|
726
|
+
var r = await query(
|
|
727
|
+
"SELECT location_code, reason, currency, " +
|
|
728
|
+
" SUM(quantity) AS units, " +
|
|
729
|
+
" SUM(cost_impact_minor) AS cost_impact_minor " +
|
|
730
|
+
" FROM inventory_writeoffs " +
|
|
731
|
+
" WHERE occurred_at >= ?1 AND occurred_at < ?2 " +
|
|
732
|
+
" AND status = 'recorded' " +
|
|
733
|
+
" AND location_code IS NOT NULL " +
|
|
734
|
+
" GROUP BY location_code, reason, currency",
|
|
735
|
+
[window.from, window.to],
|
|
736
|
+
);
|
|
737
|
+
var perLocation = Object.create(null);
|
|
738
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
739
|
+
var row = r.rows[i];
|
|
740
|
+
var loc = String(row.location_code);
|
|
741
|
+
var slot = perLocation[loc];
|
|
742
|
+
if (!slot) {
|
|
743
|
+
slot = perLocation[loc] = {
|
|
744
|
+
location_code: loc,
|
|
745
|
+
units: 0,
|
|
746
|
+
cost_impact_minor: null,
|
|
747
|
+
currency: null,
|
|
748
|
+
reason_units: Object.create(null),
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
var rowUnits = Number(row.units) || 0;
|
|
752
|
+
slot.units += rowUnits;
|
|
753
|
+
slot.reason_units[row.reason] = (slot.reason_units[row.reason] || 0) + rowUnits;
|
|
754
|
+
if (row.cost_impact_minor != null) {
|
|
755
|
+
if (slot.currency != null && slot.currency !== row.currency) {
|
|
756
|
+
throw new TypeError("shrinkage-report.topLossLocations: location '" + loc +
|
|
757
|
+
"' spans multiple currencies (" + slot.currency + ", " + row.currency +
|
|
758
|
+
") — reconcile before reporting");
|
|
759
|
+
}
|
|
760
|
+
slot.currency = row.currency;
|
|
761
|
+
slot.cost_impact_minor = (slot.cost_impact_minor || 0) + Number(row.cost_impact_minor);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
var keys = Object.keys(perLocation);
|
|
765
|
+
var rows = keys.map(function (k) {
|
|
766
|
+
var slot = perLocation[k];
|
|
767
|
+
// Pick the top reason — most units; tie-break alphabetic
|
|
768
|
+
// so the column is deterministic across refreshes.
|
|
769
|
+
var topReason = null;
|
|
770
|
+
var topUnits = -1;
|
|
771
|
+
var reasonKeys = Object.keys(slot.reason_units).sort();
|
|
772
|
+
for (var j = 0; j < reasonKeys.length; j += 1) {
|
|
773
|
+
var u = slot.reason_units[reasonKeys[j]];
|
|
774
|
+
if (u > topUnits) { topUnits = u; topReason = reasonKeys[j]; }
|
|
775
|
+
}
|
|
776
|
+
return {
|
|
777
|
+
location_code: slot.location_code,
|
|
778
|
+
units: slot.units,
|
|
779
|
+
cost_impact_minor: slot.cost_impact_minor,
|
|
780
|
+
currency: slot.currency,
|
|
781
|
+
reason_top: topReason,
|
|
782
|
+
reason_top_units: topUnits < 0 ? 0 : topUnits,
|
|
783
|
+
};
|
|
784
|
+
}).filter(function (r2) { return r2.units > 0; });
|
|
785
|
+
// Sort: cost-impact desc (rows with cost first), units desc
|
|
786
|
+
// tie-break, location asc final tie-break for determinism.
|
|
787
|
+
rows.sort(function (a, b) {
|
|
788
|
+
var aCost = a.cost_impact_minor == null ? -1 : a.cost_impact_minor;
|
|
789
|
+
var bCost = b.cost_impact_minor == null ? -1 : b.cost_impact_minor;
|
|
790
|
+
if (aCost !== bCost) return bCost - aCost;
|
|
791
|
+
if (a.units !== b.units) return b.units - a.units;
|
|
792
|
+
return a.location_code < b.location_code ? -1 : 1;
|
|
793
|
+
});
|
|
794
|
+
return rows.slice(0, rawLimit);
|
|
795
|
+
});
|
|
796
|
+
},
|
|
797
|
+
|
|
798
|
+
// Top-N SKUs ranked by units desc (primary), cost-impact desc
|
|
799
|
+
// tie-break. The primary-sort flip vs `topLossLocations` matches
|
|
800
|
+
// loss-prevention intuition — units-walking is the LP signal,
|
|
801
|
+
// cost-of-stock is the finance signal.
|
|
802
|
+
topShrinkageSkus: async function (input) {
|
|
803
|
+
var window = _resolveWindow(input);
|
|
804
|
+
var rawLimit = (input && input.limit) == null ? DEFAULT_LIMIT : input.limit;
|
|
805
|
+
_limit(rawLimit, "limit");
|
|
806
|
+
var cacheOpts = input && input.cache === true ? { cache: true, cache_ttl_ms: input.cache_ttl_ms } : null;
|
|
807
|
+
var params = { from: window.from, to: window.to, limit: rawLimit };
|
|
808
|
+
|
|
809
|
+
return _withCache("top_skus", params, cacheOpts, window.from, window.to, async function () {
|
|
810
|
+
var r = await query(
|
|
811
|
+
"SELECT sku, location_code, currency, " +
|
|
812
|
+
" SUM(quantity) AS units, " +
|
|
813
|
+
" SUM(cost_impact_minor) AS cost_impact_minor " +
|
|
814
|
+
" FROM inventory_writeoffs " +
|
|
815
|
+
" WHERE occurred_at >= ?1 AND occurred_at < ?2 " +
|
|
816
|
+
" AND status = 'recorded' " +
|
|
817
|
+
" GROUP BY sku, location_code, currency",
|
|
818
|
+
[window.from, window.to],
|
|
819
|
+
);
|
|
820
|
+
var perSku = Object.create(null);
|
|
821
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
822
|
+
var row = r.rows[i];
|
|
823
|
+
var sku = String(row.sku);
|
|
824
|
+
var slot = perSku[sku];
|
|
825
|
+
if (!slot) {
|
|
826
|
+
slot = perSku[sku] = {
|
|
827
|
+
sku: sku,
|
|
828
|
+
units: 0,
|
|
829
|
+
cost_impact_minor: null,
|
|
830
|
+
currency: null,
|
|
831
|
+
location_units: Object.create(null),
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
var rowUnits = Number(row.units) || 0;
|
|
835
|
+
slot.units += rowUnits;
|
|
836
|
+
var loc = row.location_code == null ? "(global)" : String(row.location_code);
|
|
837
|
+
slot.location_units[loc] = (slot.location_units[loc] || 0) + rowUnits;
|
|
838
|
+
if (row.cost_impact_minor != null) {
|
|
839
|
+
if (slot.currency != null && slot.currency !== row.currency) {
|
|
840
|
+
throw new TypeError("shrinkage-report.topShrinkageSkus: sku '" + sku +
|
|
841
|
+
"' spans multiple currencies (" + slot.currency + ", " + row.currency +
|
|
842
|
+
") — reconcile before reporting");
|
|
843
|
+
}
|
|
844
|
+
slot.currency = row.currency;
|
|
845
|
+
slot.cost_impact_minor = (slot.cost_impact_minor || 0) + Number(row.cost_impact_minor);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
var rows = Object.keys(perSku).map(function (k) {
|
|
849
|
+
var slot = perSku[k];
|
|
850
|
+
var topLoc = null;
|
|
851
|
+
var topUnits = -1;
|
|
852
|
+
var locKeys = Object.keys(slot.location_units).sort();
|
|
853
|
+
for (var j = 0; j < locKeys.length; j += 1) {
|
|
854
|
+
var u = slot.location_units[locKeys[j]];
|
|
855
|
+
if (u > topUnits) { topUnits = u; topLoc = locKeys[j]; }
|
|
856
|
+
}
|
|
857
|
+
return {
|
|
858
|
+
sku: slot.sku,
|
|
859
|
+
units: slot.units,
|
|
860
|
+
cost_impact_minor: slot.cost_impact_minor,
|
|
861
|
+
currency: slot.currency,
|
|
862
|
+
location_top: topLoc,
|
|
863
|
+
location_top_units: topUnits < 0 ? 0 : topUnits,
|
|
864
|
+
};
|
|
865
|
+
}).filter(function (r2) { return r2.units > 0; });
|
|
866
|
+
rows.sort(function (a, b) {
|
|
867
|
+
if (a.units !== b.units) return b.units - a.units;
|
|
868
|
+
var aCost = a.cost_impact_minor == null ? -1 : a.cost_impact_minor;
|
|
869
|
+
var bCost = b.cost_impact_minor == null ? -1 : b.cost_impact_minor;
|
|
870
|
+
if (aCost !== bCost) return bCost - aCost;
|
|
871
|
+
return a.sku < b.sku ? -1 : 1;
|
|
872
|
+
});
|
|
873
|
+
return rows.slice(0, rawLimit);
|
|
874
|
+
});
|
|
875
|
+
},
|
|
876
|
+
|
|
877
|
+
// Side-by-side category comparison. The four reason-categories
|
|
878
|
+
// (operational / perishable / external / deliberate) cover every
|
|
879
|
+
// reason enum value — operator dashboards typically show this as
|
|
880
|
+
// a stacked-bar chart with the most-units category on top.
|
|
881
|
+
categoryComparison: async function (input) {
|
|
882
|
+
var window = _resolveWindow(input);
|
|
883
|
+
var cacheOpts = input && input.cache === true ? { cache: true, cache_ttl_ms: input.cache_ttl_ms } : null;
|
|
884
|
+
var params = { from: window.from, to: window.to };
|
|
885
|
+
|
|
886
|
+
return _withCache("category_comparison", params, cacheOpts, window.from, window.to, async function () {
|
|
887
|
+
var r = await query(
|
|
888
|
+
"SELECT reason, currency, " +
|
|
889
|
+
" SUM(quantity) AS units, " +
|
|
890
|
+
" SUM(cost_impact_minor) AS cost_impact_minor " +
|
|
891
|
+
" FROM inventory_writeoffs " +
|
|
892
|
+
" WHERE occurred_at >= ?1 AND occurred_at < ?2 " +
|
|
893
|
+
" AND status = 'recorded' " +
|
|
894
|
+
" GROUP BY reason, currency",
|
|
895
|
+
[window.from, window.to],
|
|
896
|
+
);
|
|
897
|
+
var categories = Object.create(null);
|
|
898
|
+
var totalUnits = 0;
|
|
899
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
900
|
+
var row = r.rows[i];
|
|
901
|
+
var category = REASON_CATEGORY[row.reason] || "unknown";
|
|
902
|
+
var slot = categories[category];
|
|
903
|
+
if (!slot) {
|
|
904
|
+
slot = categories[category] = {
|
|
905
|
+
category: category,
|
|
906
|
+
units: 0,
|
|
907
|
+
cost_impact_minor: null,
|
|
908
|
+
currency: null,
|
|
909
|
+
reason_units: Object.create(null),
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
var rowUnits = Number(row.units) || 0;
|
|
913
|
+
slot.units += rowUnits;
|
|
914
|
+
totalUnits += rowUnits;
|
|
915
|
+
slot.reason_units[row.reason] = (slot.reason_units[row.reason] || 0) + rowUnits;
|
|
916
|
+
if (row.cost_impact_minor != null) {
|
|
917
|
+
if (slot.currency != null && slot.currency !== row.currency) {
|
|
918
|
+
throw new TypeError("shrinkage-report.categoryComparison: category '" + category +
|
|
919
|
+
"' spans multiple currencies (" + slot.currency + ", " + row.currency +
|
|
920
|
+
") — reconcile before reporting");
|
|
921
|
+
}
|
|
922
|
+
slot.currency = row.currency;
|
|
923
|
+
slot.cost_impact_minor = (slot.cost_impact_minor || 0) + Number(row.cost_impact_minor);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
var rows = Object.keys(categories).map(function (k) {
|
|
927
|
+
var slot = categories[k];
|
|
928
|
+
var topReason = null;
|
|
929
|
+
var topUnits = -1;
|
|
930
|
+
var reasonKeys = Object.keys(slot.reason_units).sort();
|
|
931
|
+
for (var j = 0; j < reasonKeys.length; j += 1) {
|
|
932
|
+
var u = slot.reason_units[reasonKeys[j]];
|
|
933
|
+
if (u > topUnits) { topUnits = u; topReason = reasonKeys[j]; }
|
|
934
|
+
}
|
|
935
|
+
return {
|
|
936
|
+
category: slot.category,
|
|
937
|
+
units: slot.units,
|
|
938
|
+
cost_impact_minor: slot.cost_impact_minor,
|
|
939
|
+
currency: slot.currency,
|
|
940
|
+
share_bps: _shareBps(slot.units, totalUnits),
|
|
941
|
+
top_reason: topReason,
|
|
942
|
+
top_reason_units: topUnits < 0 ? 0 : topUnits,
|
|
943
|
+
};
|
|
944
|
+
});
|
|
945
|
+
rows.sort(function (a, b) {
|
|
946
|
+
if (a.units !== b.units) return b.units - a.units;
|
|
947
|
+
return a.category < b.category ? -1 : 1;
|
|
948
|
+
});
|
|
949
|
+
return rows;
|
|
950
|
+
});
|
|
951
|
+
},
|
|
952
|
+
|
|
953
|
+
// Per-month rollup. bucket_start is the YYYY-MM-DD of the first
|
|
954
|
+
// day of the calendar month (UTC). Operator-facing dashboard
|
|
955
|
+
// renders this as a sparkline / area chart.
|
|
956
|
+
monthlyTrend: async function (input) {
|
|
957
|
+
var window = _resolveWindow(input, TWO_YEARS_MS);
|
|
958
|
+
var cacheOpts = input && input.cache === true ? { cache: true, cache_ttl_ms: input.cache_ttl_ms } : null;
|
|
959
|
+
var params = { from: window.from, to: window.to };
|
|
960
|
+
|
|
961
|
+
return _withCache("monthly_trend", params, cacheOpts, window.from, window.to, async function () {
|
|
962
|
+
var bucketSql = "date(occurred_at/1000, 'unixepoch', 'start of month')";
|
|
963
|
+
var r = await query(
|
|
964
|
+
"SELECT " + bucketSql + " AS bucket_start, currency, " +
|
|
965
|
+
" SUM(quantity) AS units, " +
|
|
966
|
+
" SUM(cost_impact_minor) AS cost_impact_minor " +
|
|
967
|
+
" FROM inventory_writeoffs " +
|
|
968
|
+
" WHERE occurred_at >= ?1 AND occurred_at < ?2 " +
|
|
969
|
+
" AND status = 'recorded' " +
|
|
970
|
+
" GROUP BY bucket_start, currency " +
|
|
971
|
+
" ORDER BY bucket_start ASC, currency ASC",
|
|
972
|
+
[window.from, window.to],
|
|
973
|
+
);
|
|
974
|
+
// Fold (bucket, currency) → bucket. Cost-impact across mixed
|
|
975
|
+
// currencies inside one bucket refuses, same posture as the
|
|
976
|
+
// other rollups.
|
|
977
|
+
var buckets = Object.create(null);
|
|
978
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
979
|
+
var row = r.rows[i];
|
|
980
|
+
var key = String(row.bucket_start);
|
|
981
|
+
var slot = buckets[key];
|
|
982
|
+
if (!slot) slot = buckets[key] = { bucket_start: key, units: 0, cost_impact_minor: null, currency: null };
|
|
983
|
+
slot.units += Number(row.units) || 0;
|
|
984
|
+
if (row.cost_impact_minor != null) {
|
|
985
|
+
if (slot.currency != null && slot.currency !== row.currency) {
|
|
986
|
+
throw new TypeError("shrinkage-report.monthlyTrend: bucket '" + key +
|
|
987
|
+
"' spans multiple currencies (" + slot.currency + ", " + row.currency +
|
|
988
|
+
") — reconcile before reporting");
|
|
989
|
+
}
|
|
990
|
+
slot.currency = row.currency;
|
|
991
|
+
slot.cost_impact_minor = (slot.cost_impact_minor || 0) + Number(row.cost_impact_minor);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return Object.keys(buckets).sort().map(function (k) { return buckets[k]; });
|
|
995
|
+
});
|
|
996
|
+
},
|
|
997
|
+
|
|
998
|
+
// Pie-chart-shaped breakdown by reason. Adds a `color_hint` from
|
|
999
|
+
// the stable REASON_COLOR table so the dashboard wedge colors
|
|
1000
|
+
// stay consistent across refreshes without a separate theme
|
|
1001
|
+
// lookup.
|
|
1002
|
+
reasonBreakdownPie: async function (input) {
|
|
1003
|
+
var window = _resolveWindow(input);
|
|
1004
|
+
var cacheOpts = input && input.cache === true ? { cache: true, cache_ttl_ms: input.cache_ttl_ms } : null;
|
|
1005
|
+
var params = { from: window.from, to: window.to };
|
|
1006
|
+
|
|
1007
|
+
return _withCache("reason_pie", params, cacheOpts, window.from, window.to, async function () {
|
|
1008
|
+
var r = await query(
|
|
1009
|
+
"SELECT reason, SUM(quantity) AS units " +
|
|
1010
|
+
" FROM inventory_writeoffs " +
|
|
1011
|
+
" WHERE occurred_at >= ?1 AND occurred_at < ?2 " +
|
|
1012
|
+
" AND status = 'recorded' " +
|
|
1013
|
+
" GROUP BY reason",
|
|
1014
|
+
[window.from, window.to],
|
|
1015
|
+
);
|
|
1016
|
+
var totalUnits = 0;
|
|
1017
|
+
var perReason = r.rows.map(function (row) {
|
|
1018
|
+
var u = Number(row.units) || 0;
|
|
1019
|
+
totalUnits += u;
|
|
1020
|
+
return { reason: row.reason, units: u };
|
|
1021
|
+
});
|
|
1022
|
+
var rows = perReason.map(function (row) {
|
|
1023
|
+
return {
|
|
1024
|
+
reason: row.reason,
|
|
1025
|
+
units: row.units,
|
|
1026
|
+
share_bps: _shareBps(row.units, totalUnits),
|
|
1027
|
+
color_hint: REASON_COLOR[row.reason] || "#6b7280", // slate-500 fallback
|
|
1028
|
+
};
|
|
1029
|
+
});
|
|
1030
|
+
rows.sort(function (a, b) {
|
|
1031
|
+
if (a.units !== b.units) return b.units - a.units;
|
|
1032
|
+
return a.reason < b.reason ? -1 : 1;
|
|
1033
|
+
});
|
|
1034
|
+
return rows;
|
|
1035
|
+
});
|
|
1036
|
+
},
|
|
1037
|
+
|
|
1038
|
+
// Statistical-outlier detection over per-location loss. Reports
|
|
1039
|
+
// every location whose units in the window exceed
|
|
1040
|
+
// `mean + threshold_multiplier * stddev`. Returns `[]` when
|
|
1041
|
+
// fewer than 2 locations have writeoffs in the window (stddev
|
|
1042
|
+
// undefined for n<2; no anomaly call possible).
|
|
1043
|
+
flagAnomalies: async function (input) {
|
|
1044
|
+
var window = _resolveWindow(input);
|
|
1045
|
+
var threshold = (input && input.threshold_multiplier) == null ? 2.0 : input.threshold_multiplier;
|
|
1046
|
+
_finitePositive(threshold, "threshold_multiplier");
|
|
1047
|
+
var minUnits = (input && input.min_units) == null ? 1 : input.min_units;
|
|
1048
|
+
_nonNegInt(minUnits, "min_units");
|
|
1049
|
+
var cacheOpts = input && input.cache === true ? { cache: true, cache_ttl_ms: input.cache_ttl_ms } : null;
|
|
1050
|
+
var params = { from: window.from, to: window.to,
|
|
1051
|
+
threshold_multiplier: threshold, min_units: minUnits };
|
|
1052
|
+
|
|
1053
|
+
return _withCache("anomalies", params, cacheOpts, window.from, window.to, async function () {
|
|
1054
|
+
// Per-location rollup — exclude rows with null location_code
|
|
1055
|
+
// (global catalog-level write-offs can't sit on the
|
|
1056
|
+
// anomaly leaderboard; the operator looks at those
|
|
1057
|
+
// separately via the by_location field on the main report).
|
|
1058
|
+
var r = await query(
|
|
1059
|
+
"SELECT location_code, currency, " +
|
|
1060
|
+
" SUM(quantity) AS units, " +
|
|
1061
|
+
" SUM(cost_impact_minor) AS cost_impact_minor " +
|
|
1062
|
+
" FROM inventory_writeoffs " +
|
|
1063
|
+
" WHERE occurred_at >= ?1 AND occurred_at < ?2 " +
|
|
1064
|
+
" AND status = 'recorded' " +
|
|
1065
|
+
" AND location_code IS NOT NULL " +
|
|
1066
|
+
" GROUP BY location_code, currency",
|
|
1067
|
+
[window.from, window.to],
|
|
1068
|
+
);
|
|
1069
|
+
// Fold (location, currency) → location. Same currency
|
|
1070
|
+
// coherence rule as every other rollup.
|
|
1071
|
+
var bucket = Object.create(null);
|
|
1072
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
1073
|
+
var row = r.rows[i];
|
|
1074
|
+
var loc = String(row.location_code);
|
|
1075
|
+
var slot = bucket[loc];
|
|
1076
|
+
if (!slot) slot = bucket[loc] = { location_code: loc, units: 0, cost_impact_minor: null, currency: null };
|
|
1077
|
+
slot.units += Number(row.units) || 0;
|
|
1078
|
+
if (row.cost_impact_minor != null) {
|
|
1079
|
+
if (slot.currency != null && slot.currency !== row.currency) {
|
|
1080
|
+
throw new TypeError("shrinkage-report.flagAnomalies: location '" + loc +
|
|
1081
|
+
"' spans multiple currencies (" + slot.currency + ", " + row.currency +
|
|
1082
|
+
") — reconcile before reporting");
|
|
1083
|
+
}
|
|
1084
|
+
slot.currency = row.currency;
|
|
1085
|
+
slot.cost_impact_minor = (slot.cost_impact_minor || 0) + Number(row.cost_impact_minor);
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
// Honor min_units filter — locations below the floor don't
|
|
1089
|
+
// pollute the statistic.
|
|
1090
|
+
var locations = [];
|
|
1091
|
+
var locKeys = Object.keys(bucket);
|
|
1092
|
+
for (var k = 0; k < locKeys.length; k += 1) {
|
|
1093
|
+
if (bucket[locKeys[k]].units >= minUnits) locations.push(bucket[locKeys[k]]);
|
|
1094
|
+
}
|
|
1095
|
+
if (locations.length < 2) return [];
|
|
1096
|
+
|
|
1097
|
+
// Mean + sample-stddev (n-1 denominator — single observation
|
|
1098
|
+
// doesn't reach here, see the early-return above).
|
|
1099
|
+
var sum = 0;
|
|
1100
|
+
for (var s = 0; s < locations.length; s += 1) sum += locations[s].units;
|
|
1101
|
+
var mean = sum / locations.length;
|
|
1102
|
+
var sqdiff = 0;
|
|
1103
|
+
for (var t = 0; t < locations.length; t += 1) {
|
|
1104
|
+
var d = locations[t].units - mean;
|
|
1105
|
+
sqdiff += d * d;
|
|
1106
|
+
}
|
|
1107
|
+
var stddev = Math.sqrt(sqdiff / (locations.length - 1));
|
|
1108
|
+
// Degenerate case: every location has the same units count.
|
|
1109
|
+
// stddev is zero; no anomaly possible (the z-score is
|
|
1110
|
+
// undefined for every location). Return empty so the
|
|
1111
|
+
// dashboard renders "no anomalies" rather than flagging
|
|
1112
|
+
// every-or-none.
|
|
1113
|
+
if (stddev === 0) return [];
|
|
1114
|
+
|
|
1115
|
+
var thresholdUnits = mean + threshold * stddev;
|
|
1116
|
+
var flagged = [];
|
|
1117
|
+
for (var u = 0; u < locations.length; u += 1) {
|
|
1118
|
+
var loc2 = locations[u];
|
|
1119
|
+
if (loc2.units > thresholdUnits) {
|
|
1120
|
+
flagged.push({
|
|
1121
|
+
location_code: loc2.location_code,
|
|
1122
|
+
units: loc2.units,
|
|
1123
|
+
cost_impact_minor: loc2.cost_impact_minor,
|
|
1124
|
+
currency: loc2.currency,
|
|
1125
|
+
z_score: (loc2.units - mean) / stddev,
|
|
1126
|
+
mean: mean,
|
|
1127
|
+
stddev: stddev,
|
|
1128
|
+
threshold: thresholdUnits,
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
flagged.sort(function (a, b) {
|
|
1133
|
+
if (a.z_score !== b.z_score) return b.z_score - a.z_score;
|
|
1134
|
+
return a.location_code < b.location_code ? -1 : 1;
|
|
1135
|
+
});
|
|
1136
|
+
return flagged;
|
|
1137
|
+
});
|
|
1138
|
+
},
|
|
1139
|
+
|
|
1140
|
+
// Operator-cron sweep — deletes cache rows whose computed_at +
|
|
1141
|
+
// scope-TTL <= now. Without a per-row TTL column the sweep
|
|
1142
|
+
// iterates the catalog: for every known scope key, delete rows
|
|
1143
|
+
// older than its TTL. Returns the aggregate count so the cron
|
|
1144
|
+
// log can size the sweep.
|
|
1145
|
+
purgeExpired: async function (nowTs) {
|
|
1146
|
+
var ts = nowTs == null ? Date.now() : nowTs;
|
|
1147
|
+
_epochMs(ts, "nowTs");
|
|
1148
|
+
var total = 0;
|
|
1149
|
+
var scopes = Object.keys(DEFAULT_CACHE_TTL_MS);
|
|
1150
|
+
for (var i = 0; i < scopes.length; i += 1) {
|
|
1151
|
+
var key = scopes[i];
|
|
1152
|
+
var ttl = DEFAULT_CACHE_TTL_MS[key];
|
|
1153
|
+
var r = await query(
|
|
1154
|
+
"DELETE FROM shrinkage_report_cache WHERE scope_key = ?1 AND computed_at + ?2 <= ?3",
|
|
1155
|
+
[key, ttl, ts],
|
|
1156
|
+
);
|
|
1157
|
+
total += Number(r.rowCount) || 0;
|
|
1158
|
+
}
|
|
1159
|
+
return { deleted: total };
|
|
1160
|
+
},
|
|
1161
|
+
|
|
1162
|
+
// Expose injected peers for cross-call composition that will
|
|
1163
|
+
// land alongside follow-up primitives (e.g. an invalidation
|
|
1164
|
+
// hook on inventoryWriteoffs.recordWriteoff). Operators that
|
|
1165
|
+
// don't wire these get null — every read-path verb above works
|
|
1166
|
+
// without them.
|
|
1167
|
+
_peers: {
|
|
1168
|
+
inventoryWriteoffs: writeoffsPeer,
|
|
1169
|
+
costLayers: costLayersPeer,
|
|
1170
|
+
order: orderPeer,
|
|
1171
|
+
},
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
module.exports = {
|
|
1176
|
+
create: create,
|
|
1177
|
+
DAY_MS: DAY_MS,
|
|
1178
|
+
TWO_YEARS_MS: TWO_YEARS_MS,
|
|
1179
|
+
DEFAULT_CACHE_TTL_MS: DEFAULT_CACHE_TTL_MS,
|
|
1180
|
+
REASON_CATEGORY: REASON_CATEGORY,
|
|
1181
|
+
REASON_COLOR: REASON_COLOR,
|
|
1182
|
+
};
|