@blamejs/blamejs-shop 0.0.72 → 0.0.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,791 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.fulfillmentSLA
4
+ * @title Fulfillment SLA — per-order shipping + delivery deadlines
5
+ *
6
+ * @intro
7
+ * Operators commit to service levels — "standard ships in 48h",
8
+ * "same-day if placed before 2pm local", "overnight delivers within
9
+ * 24h" — and need to know which orders are breaching that
10
+ * commitment, by how much, and which policies are the worst
11
+ * offenders. This primitive owns the policy catalog, the per-order
12
+ * deadline computation, the breach log, and the on-time-rate /
13
+ * forecast reads that drive the operator's SLA dashboard.
14
+ *
15
+ * The shape:
16
+ *
17
+ * var sla = bShop.fulfillmentSLA.create({
18
+ * query: q,
19
+ * order: ord, // optional — lookups when wired
20
+ * notifications: notif, // optional — recordBreach fan-out
21
+ * });
22
+ *
23
+ * await sla.definePolicy({
24
+ * slug: "std-48",
25
+ * priority: "standard",
26
+ * ship_within_hours: 24,
27
+ * deliver_within_hours: 48,
28
+ * });
29
+ *
30
+ * await sla.definePolicy({
31
+ * slug: "same-day-2pm",
32
+ * priority: "same_day",
33
+ * ship_within_hours: 8,
34
+ * deliver_within_hours: 24,
35
+ * cutoff_local_time: "14:00",
36
+ * timezone: "America/Los_Angeles",
37
+ * });
38
+ *
39
+ * var ev = await sla.evaluateOrder({
40
+ * order_id: o,
41
+ * order_snapshot: { priority: "standard", placed_at: t0 },
42
+ * });
43
+ * // { ship_by, deliver_by, hours_to_ship, hours_to_deliver,
44
+ * // slack_hours, policy_slug, priority }
45
+ *
46
+ * await sla.recordBreach({
47
+ * order_id: o,
48
+ * breach_type: "ship",
49
+ * hours_over: 6.5,
50
+ * });
51
+ *
52
+ * await sla.currentBreaches({ severity: "critical" });
53
+ * await sla.breachesForOrder(o);
54
+ * await sla.metricsForPolicy({ slug, from, to });
55
+ * await sla.topBreachingPolicies({ from, to, limit: 5 });
56
+ * await sla.forecastImpact({ open_orders: [...] });
57
+ *
58
+ * Policy lookup keys off `priority` — the order snapshot's `priority`
59
+ * selects the matching active policy. If multiple policies share a
60
+ * priority (operator A/B-tests two same-day SLAs), the most recently
61
+ * updated active one wins; that pick is reflected in `policy_slug`
62
+ * on the evaluation result so the operator can audit which policy
63
+ * actually drove the deadline.
64
+ *
65
+ * Deadline math is anchored on `placed_at`, not on a chained-off
66
+ * ship_by. A handoff slip never silently extends the delivery
67
+ * commitment.
68
+ *
69
+ * `cutoff_local_time` (HH:MM) + `timezone` (IANA) define a same-day
70
+ * cutoff. Orders placed AT or BEFORE the local cutoff use placed_at
71
+ * as the clock-start; orders placed AFTER roll forward to the next
72
+ * day's 00:00 local. Same-day / expedited policies typically set the
73
+ * cutoff; standard / overnight typically don't. Both fields are NULL
74
+ * for always-on policies; the cutoff math is skipped and placed_at is
75
+ * the clock start.
76
+ *
77
+ * Breach severity buckets (derived at insert time so dashboards don't
78
+ * recompute per fetch):
79
+ * - minor — hours_over < 24
80
+ * - major — 24 <= hours_over < 72
81
+ * - critical — hours_over >= 72
82
+ *
83
+ * Composition:
84
+ * - b.guardUuid — order_id is UUID-shape validated at the entry
85
+ * point.
86
+ * - b.uuid.v7 — breach row PKs (sortable; the breach log read
87
+ * sorts by id desc as a tiebreaker after recorded_at).
88
+ * - notifications.enqueue — when wired, every recordBreach enqueues
89
+ * an operator alert keyed on the policy + severity. The dispatch
90
+ * is drop-silent inside try/catch so a notifications outage never
91
+ * refuses a breach record.
92
+ * - order.get — when wired, evaluateOrder accepts an `order_id`
93
+ * alone (no snapshot) and resolves the priority + placed_at
94
+ * through the order primitive; storefronts that pass a snapshot
95
+ * skip the lookup.
96
+ *
97
+ * Monotonic clock: operator-driven calls (definePolicy followed by
98
+ * evaluateOrder in the same millisecond) need strictly-increasing
99
+ * timestamps so a sort-by-updated_at returns the rows in issue
100
+ * order. _now() bumps by 1ms on ties.
101
+ *
102
+ * @related b.uuid, b.guardUuid, order, notifications
103
+ */
104
+
105
+ var bShop;
106
+ function _b() {
107
+ if (!bShop) bShop = require("./index");
108
+ return bShop.framework;
109
+ }
110
+
111
+ // ---- constants ----------------------------------------------------------
112
+
113
+ var MAX_SLUG_LEN = 64;
114
+ var MAX_LIST_LIMIT = 500;
115
+ var DEFAULT_LIMIT = 100;
116
+ var MAX_HOURS = 24 * 365; // one-year ceiling — anything longer is operator error
117
+ var MIN_HOURS = 0.0001; // sub-second-granularity guard against zero / negative
118
+
119
+ var PRIORITIES = Object.freeze(["standard", "expedited", "overnight", "same_day"]);
120
+ var BREACH_TYPES = Object.freeze(["ship", "deliver"]);
121
+ var SEVERITIES = Object.freeze(["minor", "major", "critical"]);
122
+
123
+ var SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
124
+ var CUTOFF_RE = /^([01][0-9]|2[0-3]):([0-5][0-9])$/;
125
+ var TIMEZONE_RE = /^[A-Za-z][A-Za-z0-9+_\-/]{0,63}$/;
126
+
127
+ var MS_PER_HOUR = 60 * 60 * 1000;
128
+ var MS_PER_DAY = 24 * MS_PER_HOUR;
129
+
130
+ var SEVERITY_MINOR_MAX = 24;
131
+ var SEVERITY_MAJOR_MAX = 72;
132
+
133
+ // ---- monotonic clock ----------------------------------------------------
134
+ //
135
+ // Operator-driven calls land in the same millisecond on fast machines
136
+ // (definePolicy followed by evaluateOrder in a test, for instance).
137
+ // Bumping by 1ms on a tie keeps the timeline strictly increasing so a
138
+ // sort-by-updated_at read returns rows in the order they were issued.
139
+
140
+ var _lastTs = 0;
141
+ function _now() {
142
+ var t = Date.now();
143
+ if (t <= _lastTs) { t = _lastTs + 1; }
144
+ _lastTs = t;
145
+ return t;
146
+ }
147
+
148
+ // ---- validators --------------------------------------------------------
149
+
150
+ function _orderId(s) {
151
+ try {
152
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
153
+ } catch (e) {
154
+ throw new TypeError("fulfillment-sla: order_id — " + (e && e.message || "invalid UUID"));
155
+ }
156
+ }
157
+
158
+ function _slug(s, label) {
159
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
160
+ throw new TypeError("fulfillment-sla: " + (label || "slug") +
161
+ " must match /^[a-z0-9][a-z0-9._-]*$/ (≤ " + MAX_SLUG_LEN + " chars)");
162
+ }
163
+ return s;
164
+ }
165
+
166
+ function _priority(s) {
167
+ if (PRIORITIES.indexOf(s) === -1) {
168
+ throw new TypeError("fulfillment-sla: priority must be one of " +
169
+ PRIORITIES.join(", ") + ", got " + JSON.stringify(s));
170
+ }
171
+ }
172
+
173
+ function _breachType(s) {
174
+ if (BREACH_TYPES.indexOf(s) === -1) {
175
+ throw new TypeError("fulfillment-sla: breach_type must be one of " +
176
+ BREACH_TYPES.join(", ") + ", got " + JSON.stringify(s));
177
+ }
178
+ }
179
+
180
+ function _severity(s) {
181
+ if (SEVERITIES.indexOf(s) === -1) {
182
+ throw new TypeError("fulfillment-sla: severity must be one of " +
183
+ SEVERITIES.join(", ") + ", got " + JSON.stringify(s));
184
+ }
185
+ }
186
+
187
+ function _hours(n, label) {
188
+ if (typeof n !== "number" || !isFinite(n) || n < MIN_HOURS || n > MAX_HOURS) {
189
+ throw new TypeError("fulfillment-sla: " + label +
190
+ " must be a finite number in (" + MIN_HOURS + ", " + MAX_HOURS + "]");
191
+ }
192
+ }
193
+
194
+ function _hoursOver(n) {
195
+ if (typeof n !== "number" || !isFinite(n) || n < 0 || n > MAX_HOURS) {
196
+ throw new TypeError("fulfillment-sla: hours_over must be a non-negative finite number ≤ " + MAX_HOURS);
197
+ }
198
+ }
199
+
200
+ function _epochMs(n, label) {
201
+ if (!Number.isInteger(n) || n < 0) {
202
+ throw new TypeError("fulfillment-sla: " + label + " must be a non-negative integer (epoch ms)");
203
+ }
204
+ }
205
+
206
+ function _cutoffLocalTime(s) {
207
+ if (s == null) return null;
208
+ if (typeof s !== "string" || !CUTOFF_RE.test(s)) {
209
+ throw new TypeError("fulfillment-sla: cutoff_local_time must be HH:MM (24h), got " + JSON.stringify(s));
210
+ }
211
+ return s;
212
+ }
213
+
214
+ function _timezone(s) {
215
+ if (s == null) return null;
216
+ if (typeof s !== "string" || !TIMEZONE_RE.test(s)) {
217
+ throw new TypeError("fulfillment-sla: timezone must be an IANA-shape name (alnum + + _ - /), got " + JSON.stringify(s));
218
+ }
219
+ // Validate the zone is actually recognised by the runtime — an
220
+ // operator that misspells "Americ/Los_Angeles" gets the typo caught
221
+ // at definePolicy time rather than at evaluateOrder time. Skipped
222
+ // when the zone is the special "UTC" alias which every runtime
223
+ // supports.
224
+ try {
225
+ new Intl.DateTimeFormat("en-US", { timeZone: s });
226
+ } catch (_e) {
227
+ throw new TypeError("fulfillment-sla: timezone " + JSON.stringify(s) +
228
+ " is not recognised by the runtime ICU data");
229
+ }
230
+ return s;
231
+ }
232
+
233
+ function _limit(n) {
234
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
235
+ throw new TypeError("fulfillment-sla: limit must be an integer in 1..." + MAX_LIST_LIMIT);
236
+ }
237
+ }
238
+
239
+ // ---- helpers -----------------------------------------------------------
240
+
241
+ function _severityFor(hoursOver) {
242
+ if (hoursOver < SEVERITY_MINOR_MAX) return "minor";
243
+ if (hoursOver < SEVERITY_MAJOR_MAX) return "major";
244
+ return "critical";
245
+ }
246
+
247
+ // Compute the clock-start for SLA math given the order's placed_at, the
248
+ // optional cutoff (HH:MM local), and the IANA timezone. Orders placed
249
+ // at or before the cutoff anchor on placed_at directly; orders placed
250
+ // AFTER the cutoff roll forward to the next local-day's 00:00. Returns
251
+ // epoch ms.
252
+ //
253
+ // The Intl-based local-time parse is exact (no floating-point drift)
254
+ // because the runtime's ICU is the same source of truth used to render
255
+ // the operator's dashboard.
256
+ function _clockStart(placedAt, cutoffLocalTime, timezone) {
257
+ if (cutoffLocalTime == null || timezone == null) return placedAt;
258
+
259
+ var parts = new Intl.DateTimeFormat("en-US", {
260
+ timeZone: timezone,
261
+ hourCycle: "h23",
262
+ year: "numeric", month: "2-digit", day: "2-digit",
263
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
264
+ }).formatToParts(new Date(placedAt));
265
+ var byType = {};
266
+ for (var i = 0; i < parts.length; i += 1) {
267
+ byType[parts[i].type] = parts[i].value;
268
+ }
269
+ var localHour = Number(byType.hour);
270
+ var localMinute = Number(byType.minute);
271
+ var cutoffParts = cutoffLocalTime.split(":");
272
+ var cutoffHour = Number(cutoffParts[0]);
273
+ var cutoffMinute = Number(cutoffParts[1]);
274
+
275
+ var localTotalMin = localHour * 60 + localMinute;
276
+ var cutoffTotalMin = cutoffHour * 60 + cutoffMinute;
277
+
278
+ if (localTotalMin <= cutoffTotalMin) {
279
+ // Placed before/at the cutoff — same-day clock-start at placed_at.
280
+ return placedAt;
281
+ }
282
+
283
+ // Placed after — roll forward to next local day's 00:00.
284
+ // Strategy: add 24h to placed_at, then "snap" to local-midnight by
285
+ // subtracting the local time-of-day for that rolled-forward moment.
286
+ var nextDayMs = placedAt + MS_PER_DAY;
287
+ var nextParts = new Intl.DateTimeFormat("en-US", {
288
+ timeZone: timezone,
289
+ hourCycle: "h23",
290
+ hour: "2-digit", minute: "2-digit", second: "2-digit",
291
+ }).formatToParts(new Date(nextDayMs));
292
+ var nextByType = {};
293
+ for (var j = 0; j < nextParts.length; j += 1) {
294
+ nextByType[nextParts[j].type] = nextParts[j].value;
295
+ }
296
+ var nextHour = Number(nextByType.hour);
297
+ var nextMinute = Number(nextByType.minute);
298
+ var nextSecond = Number(nextByType.second);
299
+ var localOffsetMs = (nextHour * 3600 + nextMinute * 60 + nextSecond) * 1000;
300
+ return nextDayMs - localOffsetMs;
301
+ }
302
+
303
+ // ---- factory ------------------------------------------------------------
304
+
305
+ function create(opts) {
306
+ opts = opts || {};
307
+ var query = opts.query;
308
+ if (!query) {
309
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
310
+ }
311
+
312
+ // order is optional — when wired, evaluateOrder accepts an `order_id`
313
+ // alone and resolves priority + placed_at through `order.get`.
314
+ // Storefronts that already hold a snapshot skip the lookup.
315
+ var orderPrim = opts.order || null;
316
+ if (orderPrim && typeof orderPrim.get !== "function") {
317
+ throw new TypeError("fulfillment-sla.create: opts.order must expose a get(order_id) method");
318
+ }
319
+
320
+ // notifications is optional — recordBreach fan-out. Drop-silent on
321
+ // outage so a notifications failure never refuses a breach record.
322
+ var notifications = opts.notifications || null;
323
+ if (notifications && typeof notifications.enqueue !== "function") {
324
+ throw new TypeError("fulfillment-sla.create: opts.notifications must expose an enqueue(input) method");
325
+ }
326
+
327
+ // Look up the active policy for a priority. Most-recently-updated
328
+ // active row wins when multiple policies share a priority (operator
329
+ // A/B-testing).
330
+ async function _policyForPriority(priority) {
331
+ var r = await query(
332
+ "SELECT * FROM fulfillment_sla_policies " +
333
+ "WHERE priority = ?1 AND archived_at IS NULL " +
334
+ "ORDER BY updated_at DESC, slug ASC LIMIT 1",
335
+ [priority],
336
+ );
337
+ return r.rows[0] || null;
338
+ }
339
+
340
+ async function _policyBySlug(slug) {
341
+ var r = await query("SELECT * FROM fulfillment_sla_policies WHERE slug = ?1", [slug]);
342
+ return r.rows[0] || null;
343
+ }
344
+
345
+ // Resolve the (priority, placed_at) pair from a caller-supplied
346
+ // snapshot OR via the wired `order.get` handle. The snapshot always
347
+ // wins when present so storefronts that already hold the order
348
+ // don't pay for a second read.
349
+ async function _resolveSnapshot(input) {
350
+ if (input.order_snapshot && typeof input.order_snapshot === "object") {
351
+ _priority(input.order_snapshot.priority);
352
+ _epochMs(input.order_snapshot.placed_at, "order_snapshot.placed_at");
353
+ return {
354
+ priority: input.order_snapshot.priority,
355
+ placed_at: input.order_snapshot.placed_at,
356
+ };
357
+ }
358
+ if (!orderPrim) {
359
+ throw new TypeError("fulfillment-sla.evaluateOrder: order_snapshot required when no order handle is wired");
360
+ }
361
+ var row = await orderPrim.get(input.order_id);
362
+ if (!row) {
363
+ throw new TypeError("fulfillment-sla.evaluateOrder: order " + input.order_id + " not found via order.get");
364
+ }
365
+ _priority(row.priority);
366
+ _epochMs(row.placed_at, "order.placed_at");
367
+ return { priority: row.priority, placed_at: row.placed_at };
368
+ }
369
+
370
+ return {
371
+ // Upsert a per-priority policy. Re-defining the same slug updates
372
+ // the deadline fields + cutoff in place; `archived_at` is cleared
373
+ // so a re-define un-archives a previously-archived policy.
374
+ definePolicy: async function (input) {
375
+ if (!input || typeof input !== "object") {
376
+ throw new TypeError("fulfillment-sla.definePolicy: input object required");
377
+ }
378
+ var slug = _slug(input.slug, "slug");
379
+ _priority(input.priority);
380
+ _hours(input.ship_within_hours, "ship_within_hours");
381
+ _hours(input.deliver_within_hours, "deliver_within_hours");
382
+ if (input.deliver_within_hours < input.ship_within_hours) {
383
+ throw new TypeError("fulfillment-sla.definePolicy: deliver_within_hours must be >= ship_within_hours");
384
+ }
385
+ var cutoff = _cutoffLocalTime(input.cutoff_local_time);
386
+ var tz = _timezone(input.timezone);
387
+ // Both-or-neither: cutoff requires a timezone (otherwise the
388
+ // interpretation is ambiguous); a bare timezone with no cutoff
389
+ // is meaningless. Refuse the half-configured form at define-time.
390
+ if ((cutoff == null) !== (tz == null)) {
391
+ throw new TypeError("fulfillment-sla.definePolicy: cutoff_local_time and timezone must both be set or both be omitted");
392
+ }
393
+ var ts = _now();
394
+ var existing = await _policyBySlug(slug);
395
+ if (existing) {
396
+ await query(
397
+ "UPDATE fulfillment_sla_policies SET priority = ?1, ship_within_hours = ?2, " +
398
+ "deliver_within_hours = ?3, cutoff_local_time = ?4, timezone = ?5, " +
399
+ "archived_at = NULL, updated_at = ?6 WHERE slug = ?7",
400
+ [input.priority, input.ship_within_hours, input.deliver_within_hours,
401
+ cutoff, tz, ts, slug],
402
+ );
403
+ } else {
404
+ await query(
405
+ "INSERT INTO fulfillment_sla_policies (slug, priority, ship_within_hours, " +
406
+ "deliver_within_hours, cutoff_local_time, timezone, created_at, updated_at) " +
407
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)",
408
+ [slug, input.priority, input.ship_within_hours, input.deliver_within_hours,
409
+ cutoff, tz, ts],
410
+ );
411
+ }
412
+ return await _policyBySlug(slug);
413
+ },
414
+
415
+ // Soft-delete a policy. Historical breaches still resolve their
416
+ // policy_slug; future evaluateOrder calls won't pick the archived
417
+ // row.
418
+ archivePolicy: async function (slug) {
419
+ _slug(slug, "slug");
420
+ var ts = _now();
421
+ var r = await query(
422
+ "UPDATE fulfillment_sla_policies SET archived_at = ?1, updated_at = ?1 " +
423
+ "WHERE slug = ?2 AND archived_at IS NULL",
424
+ [ts, slug],
425
+ );
426
+ if (r.rowCount === 0) return null;
427
+ return await _policyBySlug(slug);
428
+ },
429
+
430
+ getPolicy: async function (slug) {
431
+ _slug(slug, "slug");
432
+ return await _policyBySlug(slug);
433
+ },
434
+
435
+ listPolicies: async function (listOpts) {
436
+ listOpts = listOpts || {};
437
+ var includeArchived = listOpts.include_archived === true;
438
+ var sql, params;
439
+ if (includeArchived) {
440
+ sql = "SELECT * FROM fulfillment_sla_policies ORDER BY priority ASC, updated_at DESC";
441
+ params = [];
442
+ } else {
443
+ sql = "SELECT * FROM fulfillment_sla_policies WHERE archived_at IS NULL " +
444
+ "ORDER BY priority ASC, updated_at DESC";
445
+ params = [];
446
+ }
447
+ var r = await query(sql, params);
448
+ return r.rows;
449
+ },
450
+
451
+ // Pure read — computes the SLA-derived deadlines for one order
452
+ // without writing anything. Caller can pass `order_snapshot` to
453
+ // skip the order.get round-trip; otherwise the wired order handle
454
+ // is required.
455
+ //
456
+ // Returns:
457
+ // {
458
+ // ship_by, deliver_by, // epoch ms deadlines
459
+ // hours_to_ship, hours_to_deliver, // policy values (mirrored)
460
+ // slack_hours, // until the EARLIEST deadline
461
+ // // — negative means breached
462
+ // clock_start_at, // epoch ms (cutoff-adjusted)
463
+ // policy_slug, priority,
464
+ // }
465
+ //
466
+ // No policy for the priority → returns { status: "no_policy",
467
+ // priority } so the caller can surface "operator hasn't defined
468
+ // an SLA for expedited yet" without an exception.
469
+ evaluateOrder: async function (input) {
470
+ if (!input || typeof input !== "object") {
471
+ throw new TypeError("fulfillment-sla.evaluateOrder: input object required");
472
+ }
473
+ var orderId = _orderId(input.order_id);
474
+ var snap = await _resolveSnapshot(input);
475
+ var policy = await _policyForPriority(snap.priority);
476
+ if (!policy) {
477
+ return { status: "no_policy", order_id: orderId, priority: snap.priority };
478
+ }
479
+ var clockStart = _clockStart(snap.placed_at, policy.cutoff_local_time, policy.timezone);
480
+ var shipBy = clockStart + policy.ship_within_hours * MS_PER_HOUR;
481
+ var deliverBy = clockStart + policy.deliver_within_hours * MS_PER_HOUR;
482
+ // Slack measured against the EARLIEST deadline so the caller's
483
+ // dashboard "next due" column is correct regardless of which
484
+ // half of the SLA is the binding one. A negative slack means
485
+ // the order has already breached at least one deadline.
486
+ var earliestDeadline = Math.min(shipBy, deliverBy);
487
+ var slackHours = (earliestDeadline - _now()) / MS_PER_HOUR;
488
+ return {
489
+ status: "evaluated",
490
+ order_id: orderId,
491
+ priority: snap.priority,
492
+ policy_slug: policy.slug,
493
+ placed_at: snap.placed_at,
494
+ clock_start_at: clockStart,
495
+ ship_by: shipBy,
496
+ deliver_by: deliverBy,
497
+ hours_to_ship: policy.ship_within_hours,
498
+ hours_to_deliver: policy.deliver_within_hours,
499
+ slack_hours: slackHours,
500
+ };
501
+ },
502
+
503
+ // Append a breach row. Severity is derived from hours_over at
504
+ // insert time. When `notifications` is wired, every breach
505
+ // enqueues an operator alert; failure to enqueue is drop-silent
506
+ // so a notifications outage never refuses the breach record.
507
+ //
508
+ // Caller passes `policy_slug` to bind the breach to a specific
509
+ // policy; absent, the priority lookup re-resolves the active
510
+ // policy. Refuses when no policy matches (a breach can't exist
511
+ // without a policy to breach against).
512
+ recordBreach: async function (input) {
513
+ if (!input || typeof input !== "object") {
514
+ throw new TypeError("fulfillment-sla.recordBreach: input object required");
515
+ }
516
+ var orderId = _orderId(input.order_id);
517
+ _breachType(input.breach_type);
518
+ _hoursOver(input.hours_over);
519
+ var policy = null;
520
+ if (input.policy_slug != null) {
521
+ _slug(input.policy_slug, "policy_slug");
522
+ policy = await _policyBySlug(input.policy_slug);
523
+ if (!policy) {
524
+ throw new TypeError("fulfillment-sla.recordBreach: policy_slug " +
525
+ JSON.stringify(input.policy_slug) + " not found");
526
+ }
527
+ } else if (input.priority != null) {
528
+ _priority(input.priority);
529
+ policy = await _policyForPriority(input.priority);
530
+ if (!policy) {
531
+ throw new TypeError("fulfillment-sla.recordBreach: no active policy for priority " +
532
+ JSON.stringify(input.priority));
533
+ }
534
+ } else {
535
+ throw new TypeError("fulfillment-sla.recordBreach: policy_slug or priority required");
536
+ }
537
+ var severity = _severityFor(input.hours_over);
538
+ var id = _b().uuid.v7();
539
+ var ts = _now();
540
+ await query(
541
+ "INSERT INTO fulfillment_sla_breaches (id, order_id, policy_slug, breach_type, " +
542
+ "hours_over, severity, recorded_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
543
+ [id, orderId, policy.slug, input.breach_type, input.hours_over, severity, ts],
544
+ );
545
+ if (notifications) {
546
+ try {
547
+ await notifications.enqueue({
548
+ recipient_id: orderId,
549
+ channel: "sla-breach",
550
+ event_type: "sla_breach_recorded",
551
+ title: "SLA breach (" + severity + ")",
552
+ body: "",
553
+ payload: {
554
+ order_id: orderId,
555
+ policy_slug: policy.slug,
556
+ breach_type: input.breach_type,
557
+ hours_over: input.hours_over,
558
+ severity: severity,
559
+ },
560
+ scheduled_at: ts,
561
+ });
562
+ } catch (_e) { /* drop-silent — notifications outage must not refuse the breach record */ }
563
+ }
564
+ return {
565
+ id: id,
566
+ order_id: orderId,
567
+ policy_slug: policy.slug,
568
+ breach_type: input.breach_type,
569
+ hours_over: input.hours_over,
570
+ severity: severity,
571
+ recorded_at: ts,
572
+ };
573
+ },
574
+
575
+ // Newest first. Severity filter is optional — absent returns all
576
+ // current breaches across the catalog, capped by `limit`
577
+ // (default 100, max 500).
578
+ currentBreaches: async function (listOpts) {
579
+ listOpts = listOpts || {};
580
+ var limit = listOpts.limit == null ? DEFAULT_LIMIT : listOpts.limit;
581
+ _limit(limit);
582
+ var sql, params;
583
+ if (listOpts.severity != null) {
584
+ _severity(listOpts.severity);
585
+ sql = "SELECT * FROM fulfillment_sla_breaches WHERE severity = ?1 " +
586
+ "ORDER BY recorded_at DESC, id DESC LIMIT ?2";
587
+ params = [listOpts.severity, limit];
588
+ } else {
589
+ sql = "SELECT * FROM fulfillment_sla_breaches " +
590
+ "ORDER BY recorded_at DESC, id DESC LIMIT ?1";
591
+ params = [limit];
592
+ }
593
+ var r = await query(sql, params);
594
+ return r.rows;
595
+ },
596
+
597
+ breachesForOrder: async function (orderId) {
598
+ var id = _orderId(orderId);
599
+ var r = await query(
600
+ "SELECT * FROM fulfillment_sla_breaches WHERE order_id = ?1 " +
601
+ "ORDER BY recorded_at DESC, id DESC",
602
+ [id],
603
+ );
604
+ return r.rows;
605
+ },
606
+
607
+ // On-time rate + lateness aggregate for one policy over [from, to].
608
+ // The breach log only contains breaches (by construction); the
609
+ // on-time count is supplied by the caller via `total_orders`
610
+ // (e.g. derived from the order primitive). Absent total_orders,
611
+ // the on_time_rate is reported as null with a note so the operator
612
+ // dashboard can fall back to showing breach counts alone.
613
+ metricsForPolicy: async function (input) {
614
+ if (!input || typeof input !== "object") {
615
+ throw new TypeError("fulfillment-sla.metricsForPolicy: input object required");
616
+ }
617
+ var slug = _slug(input.slug, "slug");
618
+ var from = input.from; _epochMs(from, "from");
619
+ var to = input.to; _epochMs(to, "to");
620
+ if (from > to) {
621
+ throw new TypeError("fulfillment-sla.metricsForPolicy: from must be <= to");
622
+ }
623
+ var totalOrders = null;
624
+ if (input.total_orders != null) {
625
+ if (!Number.isInteger(input.total_orders) || input.total_orders < 0) {
626
+ throw new TypeError("fulfillment-sla.metricsForPolicy: total_orders must be a non-negative integer or null");
627
+ }
628
+ totalOrders = input.total_orders;
629
+ }
630
+ // Aggregate breach counts + average lateness — one SQL pass
631
+ // grouped by severity so dashboards can render the breakdown
632
+ // without a second round-trip.
633
+ var r = await query(
634
+ "SELECT severity, COUNT(*) AS n, SUM(hours_over) AS sum_hours, " +
635
+ "MAX(hours_over) AS max_hours FROM fulfillment_sla_breaches " +
636
+ "WHERE policy_slug = ?1 AND recorded_at >= ?2 AND recorded_at <= ?3 " +
637
+ "GROUP BY severity",
638
+ [slug, from, to],
639
+ );
640
+ var bySev = { minor: 0, major: 0, critical: 0 };
641
+ var sumHours = 0;
642
+ var maxHours = 0;
643
+ var totalBreaches = 0;
644
+ for (var i = 0; i < r.rows.length; i += 1) {
645
+ var row = r.rows[i];
646
+ bySev[row.severity] = Number(row.n);
647
+ sumHours += Number(row.sum_hours) || 0;
648
+ if (Number(row.max_hours) > maxHours) maxHours = Number(row.max_hours);
649
+ totalBreaches += Number(row.n);
650
+ }
651
+ var avgLateness = totalBreaches === 0 ? 0 : sumHours / totalBreaches;
652
+ var onTimeRate = null;
653
+ if (totalOrders != null && totalOrders > 0) {
654
+ // Each breach corresponds to one order failing one deadline.
655
+ // An order with two breach rows (ship + deliver) counts as
656
+ // one breaching order — dedup by order_id for the on-time
657
+ // rate denominator.
658
+ var dedupQuery = await query(
659
+ "SELECT COUNT(DISTINCT order_id) AS n FROM fulfillment_sla_breaches " +
660
+ "WHERE policy_slug = ?1 AND recorded_at >= ?2 AND recorded_at <= ?3",
661
+ [slug, from, to],
662
+ );
663
+ var breachingOrders = Number(dedupQuery.rows[0].n) || 0;
664
+ var onTime = Math.max(0, totalOrders - breachingOrders);
665
+ onTimeRate = totalOrders === 0 ? 0 : onTime / totalOrders;
666
+ }
667
+ return {
668
+ policy_slug: slug,
669
+ from: from,
670
+ to: to,
671
+ total_breaches: totalBreaches,
672
+ by_severity: bySev,
673
+ average_lateness_hours: avgLateness,
674
+ worst_lateness_hours: maxHours,
675
+ on_time_rate: onTimeRate,
676
+ total_orders: totalOrders,
677
+ };
678
+ },
679
+
680
+ // Top N policies by breach count over [from, to]. Returns one row
681
+ // per policy_slug with the count + average lateness so the
682
+ // operator's "worst offenders" view ranks without a per-policy
683
+ // metricsForPolicy fan-out.
684
+ topBreachingPolicies: async function (input) {
685
+ if (!input || typeof input !== "object") {
686
+ throw new TypeError("fulfillment-sla.topBreachingPolicies: input object required");
687
+ }
688
+ var from = input.from; _epochMs(from, "from");
689
+ var to = input.to; _epochMs(to, "to");
690
+ if (from > to) {
691
+ throw new TypeError("fulfillment-sla.topBreachingPolicies: from must be <= to");
692
+ }
693
+ var limit = input.limit == null ? DEFAULT_LIMIT : input.limit;
694
+ _limit(limit);
695
+ var r = await query(
696
+ "SELECT policy_slug, COUNT(*) AS breach_count, AVG(hours_over) AS avg_hours_over, " +
697
+ "MAX(hours_over) AS worst_hours_over FROM fulfillment_sla_breaches " +
698
+ "WHERE recorded_at >= ?1 AND recorded_at <= ?2 " +
699
+ "GROUP BY policy_slug ORDER BY breach_count DESC, policy_slug ASC LIMIT ?3",
700
+ [from, to, limit],
701
+ );
702
+ return r.rows.map(function (row) {
703
+ return {
704
+ policy_slug: row.policy_slug,
705
+ breach_count: Number(row.breach_count),
706
+ average_lateness_hours: Number(row.avg_hours_over) || 0,
707
+ worst_lateness_hours: Number(row.worst_hours_over) || 0,
708
+ };
709
+ });
710
+ },
711
+
712
+ // Predicts which OPEN orders will breach. Caller supplies the
713
+ // open-order snapshots (no read against an external order primitive
714
+ // because the caller often holds an in-process page of open orders
715
+ // already). For each snapshot we re-evaluate the SLA against
716
+ // `now`, classify into:
717
+ // - already_breached (negative slack)
718
+ // - at_risk (slack < at_risk_hours, default 6)
719
+ // - on_track (slack >= at_risk_hours)
720
+ // - no_policy (no active policy for the priority)
721
+ // Returns a per-bucket breakdown + the per-order detail.
722
+ forecastImpact: async function (input) {
723
+ if (!input || typeof input !== "object") {
724
+ throw new TypeError("fulfillment-sla.forecastImpact: input object required");
725
+ }
726
+ if (!Array.isArray(input.open_orders)) {
727
+ throw new TypeError("fulfillment-sla.forecastImpact: open_orders must be an array");
728
+ }
729
+ var atRiskHours = input.at_risk_hours == null ? 6 : input.at_risk_hours;
730
+ if (typeof atRiskHours !== "number" || !isFinite(atRiskHours) || atRiskHours < 0) {
731
+ throw new TypeError("fulfillment-sla.forecastImpact: at_risk_hours must be a non-negative finite number");
732
+ }
733
+ var buckets = { already_breached: [], at_risk: [], on_track: [], no_policy: [] };
734
+ for (var i = 0; i < input.open_orders.length; i += 1) {
735
+ var snap = input.open_orders[i];
736
+ if (!snap || typeof snap !== "object") {
737
+ throw new TypeError("fulfillment-sla.forecastImpact: open_orders[" + i + "] must be an object");
738
+ }
739
+ var orderId = _orderId(snap.order_id);
740
+ _priority(snap.priority);
741
+ _epochMs(snap.placed_at, "open_orders[" + i + "].placed_at");
742
+ var policy = await _policyForPriority(snap.priority);
743
+ if (!policy) {
744
+ buckets.no_policy.push({ order_id: orderId, priority: snap.priority });
745
+ continue;
746
+ }
747
+ var clockStart = _clockStart(snap.placed_at, policy.cutoff_local_time, policy.timezone);
748
+ var shipBy = clockStart + policy.ship_within_hours * MS_PER_HOUR;
749
+ var deliverBy = clockStart + policy.deliver_within_hours * MS_PER_HOUR;
750
+ var earliest = Math.min(shipBy, deliverBy);
751
+ var slack = (earliest - _now()) / MS_PER_HOUR;
752
+ var detail = {
753
+ order_id: orderId,
754
+ priority: snap.priority,
755
+ policy_slug: policy.slug,
756
+ ship_by: shipBy,
757
+ deliver_by: deliverBy,
758
+ slack_hours: slack,
759
+ };
760
+ if (slack < 0) {
761
+ buckets.already_breached.push(detail);
762
+ } else if (slack < atRiskHours) {
763
+ buckets.at_risk.push(detail);
764
+ } else {
765
+ buckets.on_track.push(detail);
766
+ }
767
+ }
768
+ return {
769
+ already_breached_count: buckets.already_breached.length,
770
+ at_risk_count: buckets.at_risk.length,
771
+ on_track_count: buckets.on_track.length,
772
+ no_policy_count: buckets.no_policy.length,
773
+ at_risk_hours: atRiskHours,
774
+ already_breached: buckets.already_breached,
775
+ at_risk: buckets.at_risk,
776
+ on_track: buckets.on_track,
777
+ no_policy: buckets.no_policy,
778
+ };
779
+ },
780
+ };
781
+ }
782
+
783
+ module.exports = {
784
+ create: create,
785
+ PRIORITIES: PRIORITIES.slice(),
786
+ BREACH_TYPES: BREACH_TYPES.slice(),
787
+ SEVERITIES: SEVERITIES.slice(),
788
+ MAX_SLUG_LEN: MAX_SLUG_LEN,
789
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
790
+ DEFAULT_LIMIT: DEFAULT_LIMIT,
791
+ };