@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,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
|
+
};
|