@blamejs/blamejs-shop 0.0.70 → 0.0.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +42 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/loyalty-earn-rules.js +786 -0
  22. package/lib/marketing-budget.js +792 -0
  23. package/lib/operator-activity-feed.js +977 -0
  24. package/lib/operator-approvals.js +942 -0
  25. package/lib/operator-help-center.js +1020 -0
  26. package/lib/operator-inbox.js +889 -0
  27. package/lib/operator-sessions.js +701 -0
  28. package/lib/order-exchanges.js +602 -0
  29. package/lib/product-compare.js +804 -0
  30. package/lib/pwa-manifest.js +1005 -0
  31. package/lib/referral-leaderboard.js +612 -0
  32. package/lib/sales-tax-filings.js +807 -0
  33. package/lib/search-ranking.js +859 -0
  34. package/lib/shipping-insurance.js +757 -0
  35. package/lib/shrinkage-report.js +1182 -0
  36. package/lib/sidebar-widgets.js +952 -0
  37. package/lib/smart-restocking.js +1048 -0
  38. package/lib/split-shipments.js +7 -1
  39. package/lib/stock-receipts.js +834 -0
  40. package/lib/subscription-analytics.js +1032 -0
  41. package/lib/suggestion-box.js +921 -0
  42. package/lib/tax-remittance.js +625 -0
  43. package/lib/vendor-invoices.js +1021 -0
  44. package/lib/winback-campaigns.js +1350 -0
  45. package/lib/wishlist-digest.js +1133 -0
  46. 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
+ };