@blamejs/blamejs-shop 0.0.64 → 0.0.65
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 +2 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +20 -0
- package/lib/metered-usage.js +782 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/split-shipments.js +773 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.deliveryEstimate
|
|
4
|
+
* @title Delivery-estimate primitive — PDP + cart "Get it by Thursday"
|
|
5
|
+
* window calculator
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* A customer landing on a product detail page or opening the cart
|
|
9
|
+
* wants one answer: "If I order now, when will it arrive?" The
|
|
10
|
+
* storefront composes the answer out of four operator-author
|
|
11
|
+
* tables:
|
|
12
|
+
*
|
|
13
|
+
* 1. Carrier transits — for each (from_zone -> to_zone, carrier,
|
|
14
|
+
* service_level) tuple, the carrier-declared transit_days
|
|
15
|
+
* budget (business days). Operators author this from the
|
|
16
|
+
* carrier's published service guide; the primitive walks the
|
|
17
|
+
* rows when an estimate request lands.
|
|
18
|
+
*
|
|
19
|
+
* 2. Origin cutoffs — per inventory-location, the daily local-
|
|
20
|
+
* time cutoff after which today's orders ship tomorrow. The
|
|
21
|
+
* primitive resolves the request's `now` against the cutoff
|
|
22
|
+
* in the origin's IANA timezone. A request before cutoff
|
|
23
|
+
* ships today (a business day); on / after cutoff rolls to
|
|
24
|
+
* the next business day at the origin.
|
|
25
|
+
*
|
|
26
|
+
* 3. Holidays — per-region calendar dates (YYYY-MM-DD) that drop
|
|
27
|
+
* out of the business-day count for that region. The primitive
|
|
28
|
+
* applies the origin region's holidays to the ship date and
|
|
29
|
+
* the destination region's holidays to the delivery transit.
|
|
30
|
+
* The author registers a region's observed holidays once a
|
|
31
|
+
* year and the framework skips them automatically.
|
|
32
|
+
*
|
|
33
|
+
* 4. Postal-prefix -> zone mappings — for destination_postal
|
|
34
|
+
* lookup. Operators author one row per prefix-bucket they
|
|
35
|
+
* care about (`"902"` -> `"us-west"`, `"100"` -> `"us-east"`).
|
|
36
|
+
* Longest-prefix wins. A request whose destination_postal
|
|
37
|
+
* doesn't match any prefix in its country falls through with
|
|
38
|
+
* a typed reason — the storefront renders a generic transit-
|
|
39
|
+
* time bracket rather than guessing.
|
|
40
|
+
*
|
|
41
|
+
* The math is calendar-day-deterministic. `estimate` returns
|
|
42
|
+
* {ship_by_date, est_min_delivery_date, est_max_delivery_date,
|
|
43
|
+
* service_levels: [...]} where each service-level row carries its
|
|
44
|
+
* own ship_by + deliver_by + business_days count. Dates are
|
|
45
|
+
* YYYY-MM-DD strings in the origin's timezone so the storefront
|
|
46
|
+
* renders them without re-doing the timezone math.
|
|
47
|
+
*
|
|
48
|
+
* Composes:
|
|
49
|
+
* - `b.uuid.v7` — id mint for carrier_transits + holidays
|
|
50
|
+
* - `b.guardUuid` — id-shape gate on archive
|
|
51
|
+
* - Optional `inventoryLocations` handle to resolve a default
|
|
52
|
+
* `origin_location` when the caller omits it
|
|
53
|
+
* - Optional `shippingZones` handle to resolve a
|
|
54
|
+
* (destination_country, destination_region) -> zone slug when
|
|
55
|
+
* the operator authored zones but skipped postal-prefix rows
|
|
56
|
+
*
|
|
57
|
+
* Surface:
|
|
58
|
+
* defineCarrierTransit({ from_zone, to_zone, carrier,
|
|
59
|
+
* service_level, transit_days })
|
|
60
|
+
* defineCutoff({ origin_location, daily_cutoff_local_time,
|
|
61
|
+
* timezone })
|
|
62
|
+
* defineHoliday({ region, date, name })
|
|
63
|
+
* definePostalZone({ country, postal_prefix, zone })
|
|
64
|
+
* estimate({ origin_location?, destination_postal,
|
|
65
|
+
* destination_country, destination_region?,
|
|
66
|
+
* weight_grams?, requested_service_level?, now? })
|
|
67
|
+
* estimateForCart({ cart, destination, now? })
|
|
68
|
+
* listTransits({ carrier? })
|
|
69
|
+
* listCutoffs()
|
|
70
|
+
* listHolidays({ region?, from?, to? })
|
|
71
|
+
* listPostalZones({ country? })
|
|
72
|
+
* archiveTransit(transit_id)
|
|
73
|
+
* archiveHoliday(holiday_id)
|
|
74
|
+
*
|
|
75
|
+
* Storage:
|
|
76
|
+
* - `carrier_transits` (migration `0117_delivery_estimate.sql`)
|
|
77
|
+
* - `shipping_cutoffs` (migration `0117_delivery_estimate.sql`)
|
|
78
|
+
* - `shipping_holidays` (migration `0117_delivery_estimate.sql`)
|
|
79
|
+
* - `delivery_postal_zones` (migration `0117_delivery_estimate.sql`)
|
|
80
|
+
*
|
|
81
|
+
* @primitive deliveryEstimate
|
|
82
|
+
* @related b.uuid, shop.shippingZones, shop.carrierRates,
|
|
83
|
+
* shop.inventoryLocations
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
// ---- constants ----------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
var MAX_ZONE_LEN = 64;
|
|
89
|
+
var ZONE_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
90
|
+
|
|
91
|
+
var CARRIERS = Object.freeze(["ups", "fedex", "usps", "dhl", "flat_rate", "custom"]);
|
|
92
|
+
|
|
93
|
+
var MAX_SERVICE_LEVEL_LEN = 64;
|
|
94
|
+
var SERVICE_LEVEL_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
|
|
95
|
+
|
|
96
|
+
var MAX_TRANSIT_DAYS = 365;
|
|
97
|
+
|
|
98
|
+
var MAX_LOCATION_LEN = 80;
|
|
99
|
+
var LOCATION_RE = /^[a-z0-9][a-z0-9_-]{0,79}$/;
|
|
100
|
+
|
|
101
|
+
var MAX_REGION_LEN = 16;
|
|
102
|
+
// region is operator-author shorthand (`us`, `us-ca`, `gb`, `eu`); kept
|
|
103
|
+
// tight enough to refuse control bytes / arbitrary input but loose
|
|
104
|
+
// enough to admit the shapes the caller already carries in cart /
|
|
105
|
+
// customer state.
|
|
106
|
+
var REGION_RE = /^[a-z0-9][a-z0-9_-]{0,15}$/;
|
|
107
|
+
|
|
108
|
+
var DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
|
109
|
+
|
|
110
|
+
var TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
111
|
+
|
|
112
|
+
// IANA TZ name — area/city plus an optional second segment (Indian/Maldives,
|
|
113
|
+
// America/Argentina/Buenos_Aires). Refuses control bytes + plain offsets
|
|
114
|
+
// like "+0500" so the framework's Date.toLocaleString call doesn't
|
|
115
|
+
// silently coerce to UTC when handed garbage.
|
|
116
|
+
var TZ_RE = /^[A-Z][A-Za-z]+\/[A-Z][A-Za-z_]+(\/[A-Z][A-Za-z_]+)?$/;
|
|
117
|
+
|
|
118
|
+
var MAX_NAME_LEN = 200;
|
|
119
|
+
|
|
120
|
+
var MAX_POSTAL_PREFIX_LEN = 16;
|
|
121
|
+
var POSTAL_PREFIX_RE = /^[A-Za-z0-9][A-Za-z0-9 -]{0,15}$/;
|
|
122
|
+
var MAX_POSTAL_LEN = 16;
|
|
123
|
+
var POSTAL_RE = /^[A-Za-z0-9][A-Za-z0-9 -]{0,15}$/;
|
|
124
|
+
|
|
125
|
+
var COUNTRY_RE = /^[A-Z]{2}$/;
|
|
126
|
+
|
|
127
|
+
var MAX_WEIGHT_GRAMS = 1000 * 1000;
|
|
128
|
+
|
|
129
|
+
var DAY_MS = 24 * 60 * 60 * 1000;
|
|
130
|
+
|
|
131
|
+
// Lazy framework handle — matches the convention every other shop
|
|
132
|
+
// primitive uses; avoids the require cycle that would arise from
|
|
133
|
+
// importing `./index` at module-eval time.
|
|
134
|
+
var bShop;
|
|
135
|
+
function _b() {
|
|
136
|
+
if (!bShop) bShop = require("./index");
|
|
137
|
+
return bShop.framework;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- validators ---------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
function _hasControlByte(s) {
|
|
143
|
+
for (var i = 0; i < s.length; i += 1) {
|
|
144
|
+
var cc = s.charCodeAt(i);
|
|
145
|
+
if (cc <= 0x1f || cc === 0x7f) return true;
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function _zone(s, label) {
|
|
151
|
+
if (typeof s !== "string" || !s.length) {
|
|
152
|
+
throw new TypeError("deliveryEstimate: " + label + " must be a non-empty string");
|
|
153
|
+
}
|
|
154
|
+
if (s.length > MAX_ZONE_LEN) {
|
|
155
|
+
throw new TypeError("deliveryEstimate: " + label + " must be <= " + MAX_ZONE_LEN + " characters");
|
|
156
|
+
}
|
|
157
|
+
if (!ZONE_RE.test(s)) {
|
|
158
|
+
throw new TypeError(
|
|
159
|
+
"deliveryEstimate: " + label + " must match /^[a-z0-9][a-z0-9_-]{0,63}$/"
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return s;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _carrier(c) {
|
|
166
|
+
if (typeof c !== "string" || CARRIERS.indexOf(c) === -1) {
|
|
167
|
+
throw new TypeError("deliveryEstimate: carrier must be one of " + CARRIERS.join(", "));
|
|
168
|
+
}
|
|
169
|
+
return c;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _serviceLevel(s) {
|
|
173
|
+
if (typeof s !== "string" || !s.length) {
|
|
174
|
+
throw new TypeError("deliveryEstimate: service_level must be a non-empty string");
|
|
175
|
+
}
|
|
176
|
+
if (s.length > MAX_SERVICE_LEVEL_LEN) {
|
|
177
|
+
throw new TypeError("deliveryEstimate: service_level must be <= " + MAX_SERVICE_LEVEL_LEN + " characters");
|
|
178
|
+
}
|
|
179
|
+
if (!SERVICE_LEVEL_RE.test(s)) {
|
|
180
|
+
throw new TypeError(
|
|
181
|
+
"deliveryEstimate: service_level must match /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/"
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return s;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function _transitDays(n) {
|
|
188
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_TRANSIT_DAYS) {
|
|
189
|
+
throw new TypeError(
|
|
190
|
+
"deliveryEstimate: transit_days must be an integer 0.." + MAX_TRANSIT_DAYS
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return n;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function _location(s, label) {
|
|
197
|
+
if (typeof s !== "string" || !s.length) {
|
|
198
|
+
throw new TypeError("deliveryEstimate: " + label + " must be a non-empty string");
|
|
199
|
+
}
|
|
200
|
+
if (s.length > MAX_LOCATION_LEN) {
|
|
201
|
+
throw new TypeError("deliveryEstimate: " + label + " must be <= " + MAX_LOCATION_LEN + " characters");
|
|
202
|
+
}
|
|
203
|
+
if (!LOCATION_RE.test(s)) {
|
|
204
|
+
throw new TypeError(
|
|
205
|
+
"deliveryEstimate: " + label + " must match /^[a-z0-9][a-z0-9_-]{0,79}$/"
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return s;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function _region(s, label) {
|
|
212
|
+
if (typeof s !== "string" || !s.length) {
|
|
213
|
+
throw new TypeError("deliveryEstimate: " + label + " must be a non-empty string");
|
|
214
|
+
}
|
|
215
|
+
if (s.length > MAX_REGION_LEN) {
|
|
216
|
+
throw new TypeError("deliveryEstimate: " + label + " must be <= " + MAX_REGION_LEN + " characters");
|
|
217
|
+
}
|
|
218
|
+
if (!REGION_RE.test(s)) {
|
|
219
|
+
throw new TypeError(
|
|
220
|
+
"deliveryEstimate: " + label + " must match /^[a-z0-9][a-z0-9_-]{0,15}$/"
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return s;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function _date(s, label) {
|
|
227
|
+
if (typeof s !== "string" || !DATE_RE.test(s)) {
|
|
228
|
+
throw new TypeError(
|
|
229
|
+
"deliveryEstimate: " + label + " must be a YYYY-MM-DD string"
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
// Verify the date actually exists — DATE_RE admits 2026-02-30 etc.
|
|
233
|
+
// _parseYMD throws when the constructed Date doesn't round-trip.
|
|
234
|
+
_parseYMD(s, label);
|
|
235
|
+
return s;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function _time(s, label) {
|
|
239
|
+
if (typeof s !== "string" || !TIME_RE.test(s)) {
|
|
240
|
+
throw new TypeError(
|
|
241
|
+
"deliveryEstimate: " + label + " must be a HH:MM 24-hour string"
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return s;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function _tz(s, label) {
|
|
248
|
+
if (typeof s !== "string" || !TZ_RE.test(s)) {
|
|
249
|
+
throw new TypeError(
|
|
250
|
+
"deliveryEstimate: " + label + " must be an IANA timezone name (Area/City)"
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
// Validate against Intl — TZ_RE is a syntactic gate, Intl is the
|
|
254
|
+
// semantic gate. An invalid zone (e.g. "Made/Up_Place") throws here
|
|
255
|
+
// so a typo at config time surfaces loud.
|
|
256
|
+
try {
|
|
257
|
+
new Intl.DateTimeFormat("en-US", { timeZone: s });
|
|
258
|
+
} catch (_e) {
|
|
259
|
+
throw new TypeError(
|
|
260
|
+
"deliveryEstimate: " + label + " is not a known IANA timezone (got " + JSON.stringify(s) + ")"
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
return s;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function _name(s, label) {
|
|
267
|
+
if (typeof s !== "string" || !s.length) {
|
|
268
|
+
throw new TypeError("deliveryEstimate: " + label + " must be a non-empty string");
|
|
269
|
+
}
|
|
270
|
+
if (s.length > MAX_NAME_LEN) {
|
|
271
|
+
throw new TypeError("deliveryEstimate: " + label + " must be <= " + MAX_NAME_LEN + " characters");
|
|
272
|
+
}
|
|
273
|
+
if (_hasControlByte(s)) {
|
|
274
|
+
throw new TypeError("deliveryEstimate: " + label + " must not contain control characters");
|
|
275
|
+
}
|
|
276
|
+
return s;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function _country(s, label) {
|
|
280
|
+
if (typeof s !== "string" || !COUNTRY_RE.test(s)) {
|
|
281
|
+
throw new TypeError(
|
|
282
|
+
"deliveryEstimate: " + label + " must be ISO 3166-1 alpha-2 (2 uppercase letters)"
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return s;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function _postalPrefix(s, label) {
|
|
289
|
+
if (typeof s !== "string" || !s.length) {
|
|
290
|
+
throw new TypeError("deliveryEstimate: " + label + " must be a non-empty string");
|
|
291
|
+
}
|
|
292
|
+
if (s.length > MAX_POSTAL_PREFIX_LEN) {
|
|
293
|
+
throw new TypeError("deliveryEstimate: " + label + " must be <= " + MAX_POSTAL_PREFIX_LEN + " characters");
|
|
294
|
+
}
|
|
295
|
+
if (!POSTAL_PREFIX_RE.test(s)) {
|
|
296
|
+
throw new TypeError(
|
|
297
|
+
"deliveryEstimate: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9 -]{0,15}$/"
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
return s;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function _postal(s, label) {
|
|
304
|
+
if (typeof s !== "string" || !s.length) {
|
|
305
|
+
throw new TypeError("deliveryEstimate: " + label + " must be a non-empty string");
|
|
306
|
+
}
|
|
307
|
+
if (s.length > MAX_POSTAL_LEN) {
|
|
308
|
+
throw new TypeError("deliveryEstimate: " + label + " must be <= " + MAX_POSTAL_LEN + " characters");
|
|
309
|
+
}
|
|
310
|
+
if (!POSTAL_RE.test(s)) {
|
|
311
|
+
throw new TypeError(
|
|
312
|
+
"deliveryEstimate: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9 -]{0,15}$/"
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return s;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function _weightGrams(n, label) {
|
|
319
|
+
if (n == null) return null;
|
|
320
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_WEIGHT_GRAMS) {
|
|
321
|
+
throw new TypeError(
|
|
322
|
+
"deliveryEstimate: " + label + " must be a non-negative integer <= " + MAX_WEIGHT_GRAMS
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
return n;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function _epochMs(n, label) {
|
|
329
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
330
|
+
throw new TypeError("deliveryEstimate: " + label + " must be a positive integer epoch-ms");
|
|
331
|
+
}
|
|
332
|
+
return n;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function _now() { return Date.now(); }
|
|
336
|
+
|
|
337
|
+
// ---- date math ----------------------------------------------------------
|
|
338
|
+
//
|
|
339
|
+
// All math is "calendar day" against an IANA timezone. The hot path uses
|
|
340
|
+
// Intl.DateTimeFormat to decompose an epoch-ms into the wall-clock parts
|
|
341
|
+
// for the origin's timezone, then walks day-by-day forward applying
|
|
342
|
+
// weekend + holiday skips. The arithmetic is integer days; no floats,
|
|
343
|
+
// no DST drift (Intl handles the wall-clock conversion).
|
|
344
|
+
|
|
345
|
+
function _parseYMD(s, label) {
|
|
346
|
+
var y = Number(s.slice(0, 4));
|
|
347
|
+
var mo = Number(s.slice(5, 7));
|
|
348
|
+
var d = Number(s.slice(8, 10));
|
|
349
|
+
if (!Number.isInteger(y) || !Number.isInteger(mo) || !Number.isInteger(d)) {
|
|
350
|
+
throw new TypeError("deliveryEstimate: " + label + " — invalid YYYY-MM-DD digits");
|
|
351
|
+
}
|
|
352
|
+
// Round-trip via Date.UTC; if month or day overflow (e.g. Feb 30 ->
|
|
353
|
+
// Mar 02) the constructed parts won't match the input.
|
|
354
|
+
var ts = Date.UTC(y, mo - 1, d);
|
|
355
|
+
var dt = new Date(ts);
|
|
356
|
+
if (dt.getUTCFullYear() !== y || dt.getUTCMonth() !== (mo - 1) || dt.getUTCDate() !== d) {
|
|
357
|
+
throw new TypeError("deliveryEstimate: " + label + " — not a real calendar date");
|
|
358
|
+
}
|
|
359
|
+
return { y: y, m: mo, d: d, ts: ts };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function _formatYMD(parts) {
|
|
363
|
+
var y = String(parts.y);
|
|
364
|
+
var m = parts.m < 10 ? "0" + parts.m : String(parts.m);
|
|
365
|
+
var d = parts.d < 10 ? "0" + parts.d : String(parts.d);
|
|
366
|
+
return y + "-" + m + "-" + d;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Decompose an epoch-ms into {y, m, d, hh, mm, weekday(0..6 Sun..Sat)}
|
|
370
|
+
// at the requested IANA timezone. Uses Intl.DateTimeFormat parts so the
|
|
371
|
+
// result is DST-correct without manual offset bookkeeping.
|
|
372
|
+
function _wallClockIn(tz, epochMs) {
|
|
373
|
+
var fmt = new Intl.DateTimeFormat("en-US", {
|
|
374
|
+
timeZone: tz,
|
|
375
|
+
year: "numeric",
|
|
376
|
+
month: "2-digit",
|
|
377
|
+
day: "2-digit",
|
|
378
|
+
hour: "2-digit",
|
|
379
|
+
minute: "2-digit",
|
|
380
|
+
hour12: false,
|
|
381
|
+
weekday: "short",
|
|
382
|
+
});
|
|
383
|
+
var parts = fmt.formatToParts(new Date(epochMs));
|
|
384
|
+
var out = { y: 0, m: 0, d: 0, hh: 0, mm: 0, weekday: 0 };
|
|
385
|
+
for (var i = 0; i < parts.length; i += 1) {
|
|
386
|
+
var p = parts[i];
|
|
387
|
+
if (p.type === "year") out.y = Number(p.value);
|
|
388
|
+
if (p.type === "month") out.m = Number(p.value);
|
|
389
|
+
if (p.type === "day") out.d = Number(p.value);
|
|
390
|
+
if (p.type === "hour") out.hh = Number(p.value) === 24 ? 0 : Number(p.value);
|
|
391
|
+
if (p.type === "minute") out.mm = Number(p.value);
|
|
392
|
+
if (p.type === "weekday") {
|
|
393
|
+
// Sun, Mon, Tue, Wed, Thu, Fri, Sat -> 0..6
|
|
394
|
+
var map = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
|
395
|
+
out.weekday = map[p.value] != null ? map[p.value] : 0;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Add `n` calendar days to {y, m, d} parts. Returns fresh parts at the
|
|
402
|
+
// resulting wall-clock date. Walks via Date.UTC so month / year roll
|
|
403
|
+
// over naturally.
|
|
404
|
+
function _addDays(parts, n) {
|
|
405
|
+
var ts = Date.UTC(parts.y, parts.m - 1, parts.d) + n * DAY_MS;
|
|
406
|
+
var dt = new Date(ts);
|
|
407
|
+
return {
|
|
408
|
+
y: dt.getUTCFullYear(),
|
|
409
|
+
m: dt.getUTCMonth() + 1,
|
|
410
|
+
d: dt.getUTCDate(),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// JS Date.UTC weekday is Sun=0..Sat=6 — same convention as the Intl
|
|
415
|
+
// `weekday: short` map above so the two clocks agree.
|
|
416
|
+
function _weekdayOfYMD(parts) {
|
|
417
|
+
var ts = Date.UTC(parts.y, parts.m - 1, parts.d);
|
|
418
|
+
return new Date(ts).getUTCDay();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function _isWeekend(parts) {
|
|
422
|
+
var wd = _weekdayOfYMD(parts);
|
|
423
|
+
return wd === 0 || wd === 6;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Step `parts` forward by `n` business days, skipping weekends and any
|
|
427
|
+
// date present in `holidaySet` (a Set of YYYY-MM-DD strings). `n` is
|
|
428
|
+
// non-negative; 0 advances to the next business day only if `parts`
|
|
429
|
+
// itself lands on a non-business day (so the ship date math can pre-
|
|
430
|
+
// roll a Saturday cutoff to Monday before adding transit).
|
|
431
|
+
function _addBusinessDays(parts, n, holidaySet) {
|
|
432
|
+
// Pre-roll: if start is non-business, move forward to the next
|
|
433
|
+
// business day before counting transit. This is how carriers price
|
|
434
|
+
// weekend-handed parcels.
|
|
435
|
+
var cur = { y: parts.y, m: parts.m, d: parts.d };
|
|
436
|
+
while (_isNonBusiness(cur, holidaySet)) {
|
|
437
|
+
cur = _addDays(cur, 1);
|
|
438
|
+
}
|
|
439
|
+
for (var i = 0; i < n; i += 1) {
|
|
440
|
+
cur = _addDays(cur, 1);
|
|
441
|
+
while (_isNonBusiness(cur, holidaySet)) {
|
|
442
|
+
cur = _addDays(cur, 1);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return cur;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function _isNonBusiness(parts, holidaySet) {
|
|
449
|
+
if (_isWeekend(parts)) return true;
|
|
450
|
+
if (holidaySet && holidaySet[_formatYMD(parts)]) return true;
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ---- factory ------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
function create(opts) {
|
|
457
|
+
opts = opts || {};
|
|
458
|
+
var query = opts.query;
|
|
459
|
+
if (!query) {
|
|
460
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
461
|
+
}
|
|
462
|
+
var inventoryLocations = opts.inventoryLocations || null;
|
|
463
|
+
var shippingZones = opts.shippingZones || null;
|
|
464
|
+
|
|
465
|
+
function _hydrateTransit(row) {
|
|
466
|
+
if (!row) return null;
|
|
467
|
+
return {
|
|
468
|
+
id: row.id,
|
|
469
|
+
from_zone: row.from_zone,
|
|
470
|
+
to_zone: row.to_zone,
|
|
471
|
+
carrier: row.carrier,
|
|
472
|
+
service_level: row.service_level,
|
|
473
|
+
transit_days: Number(row.transit_days),
|
|
474
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
475
|
+
created_at: Number(row.created_at),
|
|
476
|
+
updated_at: Number(row.updated_at),
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function _hydrateCutoff(row) {
|
|
481
|
+
if (!row) return null;
|
|
482
|
+
return {
|
|
483
|
+
origin_location: row.origin_location,
|
|
484
|
+
daily_cutoff_local_time: row.daily_cutoff_local_time,
|
|
485
|
+
timezone: row.timezone,
|
|
486
|
+
created_at: Number(row.created_at),
|
|
487
|
+
updated_at: Number(row.updated_at),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function _hydrateHoliday(row) {
|
|
492
|
+
if (!row) return null;
|
|
493
|
+
return {
|
|
494
|
+
id: row.id,
|
|
495
|
+
region: row.region,
|
|
496
|
+
date: row.date,
|
|
497
|
+
name: row.name,
|
|
498
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
499
|
+
created_at: Number(row.created_at),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function _hydratePostalZone(row) {
|
|
504
|
+
if (!row) return null;
|
|
505
|
+
return {
|
|
506
|
+
country: row.country,
|
|
507
|
+
postal_prefix: row.postal_prefix,
|
|
508
|
+
zone: row.zone,
|
|
509
|
+
created_at: Number(row.created_at),
|
|
510
|
+
updated_at: Number(row.updated_at),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Read every live transit row that matches the requested zone pair
|
|
515
|
+
// (and optional service_level). Archived rows drop out.
|
|
516
|
+
async function _liveTransitsFor(fromZone, toZone, serviceLevel) {
|
|
517
|
+
var sql = "SELECT * FROM carrier_transits WHERE archived_at IS NULL " +
|
|
518
|
+
"AND from_zone = ?1 AND to_zone = ?2";
|
|
519
|
+
var params = [fromZone, toZone];
|
|
520
|
+
if (serviceLevel != null) {
|
|
521
|
+
sql += " AND service_level = ?3";
|
|
522
|
+
params.push(serviceLevel);
|
|
523
|
+
}
|
|
524
|
+
sql += " ORDER BY transit_days ASC, carrier ASC, service_level ASC, id ASC";
|
|
525
|
+
var r = await query(sql, params);
|
|
526
|
+
var out = [];
|
|
527
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateTransit(r.rows[i]));
|
|
528
|
+
return out;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function _holidaysForRegion(region) {
|
|
532
|
+
var r = await query(
|
|
533
|
+
"SELECT date FROM shipping_holidays WHERE region = ?1 AND archived_at IS NULL",
|
|
534
|
+
[region],
|
|
535
|
+
);
|
|
536
|
+
var set = {};
|
|
537
|
+
for (var i = 0; i < r.rows.length; i += 1) set[r.rows[i].date] = true;
|
|
538
|
+
return set;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function _resolvePostalToZone(country, postal) {
|
|
542
|
+
// Longest postal_prefix match wins. The table is small (one row
|
|
543
|
+
// per prefix-bucket the operator cares about) so the walk is fine
|
|
544
|
+
// in JS — ORDER BY length(postal_prefix) DESC keeps SQL portable.
|
|
545
|
+
var r = await query(
|
|
546
|
+
"SELECT * FROM delivery_postal_zones WHERE country = ?1 " +
|
|
547
|
+
"ORDER BY length(postal_prefix) DESC, postal_prefix ASC",
|
|
548
|
+
[country],
|
|
549
|
+
);
|
|
550
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
551
|
+
var row = r.rows[i];
|
|
552
|
+
if (postal.indexOf(row.postal_prefix) === 0) return row.zone;
|
|
553
|
+
}
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
CARRIERS: CARRIERS,
|
|
559
|
+
MAX_TRANSIT_DAYS: MAX_TRANSIT_DAYS,
|
|
560
|
+
MAX_WEIGHT_GRAMS: MAX_WEIGHT_GRAMS,
|
|
561
|
+
ZONE_RE: ZONE_RE,
|
|
562
|
+
DATE_RE: DATE_RE,
|
|
563
|
+
TIME_RE: TIME_RE,
|
|
564
|
+
COUNTRY_RE: COUNTRY_RE,
|
|
565
|
+
|
|
566
|
+
// Register a carrier transit row. The UNIQUE
|
|
567
|
+
// (from_zone, to_zone, carrier, service_level) constraint dedups
|
|
568
|
+
// — re-calling with the same tuple refreshes transit_days in
|
|
569
|
+
// place rather than stacking duplicates. Operators rotating a
|
|
570
|
+
// service-level offering call defineCarrierTransit again with
|
|
571
|
+
// the fresh number; the row id stays stable so dashboards
|
|
572
|
+
// threaded by id don't churn.
|
|
573
|
+
defineCarrierTransit: async function (input) {
|
|
574
|
+
if (!input || typeof input !== "object") {
|
|
575
|
+
throw new TypeError("deliveryEstimate.defineCarrierTransit: input object required");
|
|
576
|
+
}
|
|
577
|
+
var fromZone = _zone(input.from_zone, "from_zone");
|
|
578
|
+
var toZone = _zone(input.to_zone, "to_zone");
|
|
579
|
+
var carrier = _carrier(input.carrier);
|
|
580
|
+
var serviceLevel = _serviceLevel(input.service_level);
|
|
581
|
+
var transitDays = _transitDays(input.transit_days);
|
|
582
|
+
|
|
583
|
+
var ts = _now();
|
|
584
|
+
var existing = (await query(
|
|
585
|
+
"SELECT id, created_at FROM carrier_transits " +
|
|
586
|
+
"WHERE from_zone = ?1 AND to_zone = ?2 AND carrier = ?3 AND service_level = ?4",
|
|
587
|
+
[fromZone, toZone, carrier, serviceLevel],
|
|
588
|
+
)).rows[0];
|
|
589
|
+
var id = existing ? existing.id : _b().uuid.v7();
|
|
590
|
+
var createdAt = existing ? Number(existing.created_at) : ts;
|
|
591
|
+
|
|
592
|
+
await query(
|
|
593
|
+
"INSERT INTO carrier_transits " +
|
|
594
|
+
"(id, from_zone, to_zone, carrier, service_level, transit_days, " +
|
|
595
|
+
" archived_at, created_at, updated_at) " +
|
|
596
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?8) " +
|
|
597
|
+
"ON CONFLICT(from_zone, to_zone, carrier, service_level) DO UPDATE SET " +
|
|
598
|
+
" transit_days = excluded.transit_days, " +
|
|
599
|
+
" archived_at = NULL, " +
|
|
600
|
+
" updated_at = excluded.updated_at",
|
|
601
|
+
[id, fromZone, toZone, carrier, serviceLevel, transitDays, createdAt, ts],
|
|
602
|
+
);
|
|
603
|
+
var r = await query(
|
|
604
|
+
"SELECT * FROM carrier_transits WHERE from_zone = ?1 AND to_zone = ?2 " +
|
|
605
|
+
"AND carrier = ?3 AND service_level = ?4",
|
|
606
|
+
[fromZone, toZone, carrier, serviceLevel],
|
|
607
|
+
);
|
|
608
|
+
return _hydrateTransit(r.rows[0]);
|
|
609
|
+
},
|
|
610
|
+
|
|
611
|
+
// Upsert a cutoff per origin location. PK is origin_location —
|
|
612
|
+
// each location has exactly one cutoff (operators wanting time-
|
|
613
|
+
// band cutoffs author multiple locations rather than overloading
|
|
614
|
+
// one). Re-calling rotates the cutoff time / timezone in place.
|
|
615
|
+
defineCutoff: async function (input) {
|
|
616
|
+
if (!input || typeof input !== "object") {
|
|
617
|
+
throw new TypeError("deliveryEstimate.defineCutoff: input object required");
|
|
618
|
+
}
|
|
619
|
+
var origin = _location(input.origin_location, "origin_location");
|
|
620
|
+
var time = _time(input.daily_cutoff_local_time, "daily_cutoff_local_time");
|
|
621
|
+
var timezone = _tz(input.timezone, "timezone");
|
|
622
|
+
|
|
623
|
+
var ts = _now();
|
|
624
|
+
var existing = (await query(
|
|
625
|
+
"SELECT created_at FROM shipping_cutoffs WHERE origin_location = ?1",
|
|
626
|
+
[origin],
|
|
627
|
+
)).rows[0];
|
|
628
|
+
var createdAt = existing ? Number(existing.created_at) : ts;
|
|
629
|
+
|
|
630
|
+
await query(
|
|
631
|
+
"INSERT INTO shipping_cutoffs " +
|
|
632
|
+
"(origin_location, daily_cutoff_local_time, timezone, created_at, updated_at) " +
|
|
633
|
+
"VALUES (?1, ?2, ?3, ?4, ?5) " +
|
|
634
|
+
"ON CONFLICT(origin_location) DO UPDATE SET " +
|
|
635
|
+
" daily_cutoff_local_time = excluded.daily_cutoff_local_time, " +
|
|
636
|
+
" timezone = excluded.timezone, " +
|
|
637
|
+
" updated_at = excluded.updated_at",
|
|
638
|
+
[origin, time, timezone, createdAt, ts],
|
|
639
|
+
);
|
|
640
|
+
var r = await query(
|
|
641
|
+
"SELECT * FROM shipping_cutoffs WHERE origin_location = ?1",
|
|
642
|
+
[origin],
|
|
643
|
+
);
|
|
644
|
+
return _hydrateCutoff(r.rows[0]);
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
// Register one observed holiday per (region, date). Repeating
|
|
648
|
+
// the same (region, date) refreshes the name in place — operators
|
|
649
|
+
// re-loading the year's calendar don't churn ids. Each row carries
|
|
650
|
+
// its own uuid so an archive sweep can target a single holiday
|
|
651
|
+
// without scrubbing the whole region's set.
|
|
652
|
+
defineHoliday: async function (input) {
|
|
653
|
+
if (!input || typeof input !== "object") {
|
|
654
|
+
throw new TypeError("deliveryEstimate.defineHoliday: input object required");
|
|
655
|
+
}
|
|
656
|
+
var region = _region(input.region, "region");
|
|
657
|
+
var date = _date(input.date, "date");
|
|
658
|
+
var name = _name(input.name, "name");
|
|
659
|
+
|
|
660
|
+
var ts = _now();
|
|
661
|
+
var existing = (await query(
|
|
662
|
+
"SELECT id, created_at FROM shipping_holidays " +
|
|
663
|
+
"WHERE region = ?1 AND date = ?2",
|
|
664
|
+
[region, date],
|
|
665
|
+
)).rows[0];
|
|
666
|
+
var id = existing ? existing.id : _b().uuid.v7();
|
|
667
|
+
var createdAt = existing ? Number(existing.created_at) : ts;
|
|
668
|
+
|
|
669
|
+
await query(
|
|
670
|
+
"INSERT INTO shipping_holidays " +
|
|
671
|
+
"(id, region, date, name, archived_at, created_at) " +
|
|
672
|
+
"VALUES (?1, ?2, ?3, ?4, NULL, ?5) " +
|
|
673
|
+
"ON CONFLICT(region, date) DO UPDATE SET " +
|
|
674
|
+
" name = excluded.name, " +
|
|
675
|
+
" archived_at = NULL",
|
|
676
|
+
[id, region, date, name, createdAt],
|
|
677
|
+
);
|
|
678
|
+
var r = await query(
|
|
679
|
+
"SELECT * FROM shipping_holidays WHERE region = ?1 AND date = ?2",
|
|
680
|
+
[region, date],
|
|
681
|
+
);
|
|
682
|
+
return _hydrateHoliday(r.rows[0]);
|
|
683
|
+
},
|
|
684
|
+
|
|
685
|
+
// Operator-author postal-prefix -> zone mapping. The PK is
|
|
686
|
+
// (country, postal_prefix); repeating refreshes the zone slug
|
|
687
|
+
// in place. Longest-prefix wins at lookup time, so the operator
|
|
688
|
+
// pads in finer-grained rows over time without scrubbing the
|
|
689
|
+
// coarse-grained fallback.
|
|
690
|
+
definePostalZone: async function (input) {
|
|
691
|
+
if (!input || typeof input !== "object") {
|
|
692
|
+
throw new TypeError("deliveryEstimate.definePostalZone: input object required");
|
|
693
|
+
}
|
|
694
|
+
var country = _country(input.country, "country");
|
|
695
|
+
var prefix = _postalPrefix(input.postal_prefix, "postal_prefix");
|
|
696
|
+
var zone = _zone(input.zone, "zone");
|
|
697
|
+
|
|
698
|
+
var ts = _now();
|
|
699
|
+
var existing = (await query(
|
|
700
|
+
"SELECT created_at FROM delivery_postal_zones " +
|
|
701
|
+
"WHERE country = ?1 AND postal_prefix = ?2",
|
|
702
|
+
[country, prefix],
|
|
703
|
+
)).rows[0];
|
|
704
|
+
var createdAt = existing ? Number(existing.created_at) : ts;
|
|
705
|
+
|
|
706
|
+
await query(
|
|
707
|
+
"INSERT INTO delivery_postal_zones " +
|
|
708
|
+
"(country, postal_prefix, zone, created_at, updated_at) " +
|
|
709
|
+
"VALUES (?1, ?2, ?3, ?4, ?5) " +
|
|
710
|
+
"ON CONFLICT(country, postal_prefix) DO UPDATE SET " +
|
|
711
|
+
" zone = excluded.zone, " +
|
|
712
|
+
" updated_at = excluded.updated_at",
|
|
713
|
+
[country, prefix, zone, createdAt, ts],
|
|
714
|
+
);
|
|
715
|
+
var r = await query(
|
|
716
|
+
"SELECT * FROM delivery_postal_zones WHERE country = ?1 AND postal_prefix = ?2",
|
|
717
|
+
[country, prefix],
|
|
718
|
+
);
|
|
719
|
+
return _hydratePostalZone(r.rows[0]);
|
|
720
|
+
},
|
|
721
|
+
|
|
722
|
+
// Estimate the delivery window for a single destination. Returns
|
|
723
|
+
//
|
|
724
|
+
// {
|
|
725
|
+
// ok: bool,
|
|
726
|
+
// reason?: string, // when !ok
|
|
727
|
+
// origin_location: string,
|
|
728
|
+
// origin_zone: string,
|
|
729
|
+
// destination_zone: string,
|
|
730
|
+
// ship_by_date: "YYYY-MM-DD",
|
|
731
|
+
// est_min_delivery_date:"YYYY-MM-DD",
|
|
732
|
+
// est_max_delivery_date:"YYYY-MM-DD",
|
|
733
|
+
// service_levels: [
|
|
734
|
+
// { code, label, carrier, ship_by, deliver_by, business_days }
|
|
735
|
+
// ]
|
|
736
|
+
// }
|
|
737
|
+
//
|
|
738
|
+
// `ship_by_date` is the date today's order will physically leave
|
|
739
|
+
// the origin (today when `now` is before cutoff and today is a
|
|
740
|
+
// business day; the next business day otherwise). Each service-
|
|
741
|
+
// level row carries its own `deliver_by` after applying transit
|
|
742
|
+
// business-days from `ship_by` against the destination region's
|
|
743
|
+
// holidays + weekends.
|
|
744
|
+
//
|
|
745
|
+
// When the destination_postal doesn't resolve to a zone (no
|
|
746
|
+
// matching postal-prefix row in the destination country), the
|
|
747
|
+
// result carries `ok: false, reason: "no_destination_zone"` so
|
|
748
|
+
// the storefront can fall back to a generic transit-time bracket
|
|
749
|
+
// instead of guessing.
|
|
750
|
+
estimate: async function (input) {
|
|
751
|
+
if (!input || typeof input !== "object") {
|
|
752
|
+
throw new TypeError("deliveryEstimate.estimate: input object required");
|
|
753
|
+
}
|
|
754
|
+
var destCountry = _country(input.destination_country, "destination_country");
|
|
755
|
+
var destPostal = _postal(input.destination_postal, "destination_postal");
|
|
756
|
+
var destRegion = null;
|
|
757
|
+
if (input.destination_region != null) {
|
|
758
|
+
destRegion = _region(input.destination_region, "destination_region");
|
|
759
|
+
}
|
|
760
|
+
var requestedSvc = null;
|
|
761
|
+
if (input.requested_service_level != null) {
|
|
762
|
+
requestedSvc = _serviceLevel(input.requested_service_level);
|
|
763
|
+
}
|
|
764
|
+
_weightGrams(input.weight_grams, "weight_grams");
|
|
765
|
+
var now = input.now == null ? _now() : _epochMs(input.now, "now");
|
|
766
|
+
|
|
767
|
+
// Resolve origin_location — explicit > inventoryLocations.default()
|
|
768
|
+
// > refuse.
|
|
769
|
+
var origin = null;
|
|
770
|
+
if (input.origin_location != null) {
|
|
771
|
+
origin = _location(input.origin_location, "origin_location");
|
|
772
|
+
} else if (inventoryLocations && typeof inventoryLocations.defaultLocation === "function") {
|
|
773
|
+
var def = await inventoryLocations.defaultLocation();
|
|
774
|
+
if (def && typeof def === "object" && typeof def.slug === "string") {
|
|
775
|
+
origin = _location(def.slug, "inventoryLocations.defaultLocation().slug");
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if (!origin) {
|
|
779
|
+
throw new TypeError(
|
|
780
|
+
"deliveryEstimate.estimate: origin_location required " +
|
|
781
|
+
"(pass explicitly OR wire inventoryLocations with a defaultLocation())"
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// Resolve cutoff for the origin. Without a cutoff row the
|
|
786
|
+
// primitive refuses — every estimate is timezone-dependent, no
|
|
787
|
+
// implicit default exists. The error is config-time-style: it
|
|
788
|
+
// surfaces at the first estimate against a fresh origin so the
|
|
789
|
+
// operator gets a loud "register a cutoff for this location."
|
|
790
|
+
var cutoffRow = (await query(
|
|
791
|
+
"SELECT * FROM shipping_cutoffs WHERE origin_location = ?1",
|
|
792
|
+
[origin],
|
|
793
|
+
)).rows[0];
|
|
794
|
+
if (!cutoffRow) {
|
|
795
|
+
throw new TypeError(
|
|
796
|
+
"deliveryEstimate.estimate: no shipping_cutoffs row for origin_location '" + origin +
|
|
797
|
+
"' — call defineCutoff first"
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
var cutoff = _hydrateCutoff(cutoffRow);
|
|
801
|
+
|
|
802
|
+
// Resolve destination_zone. Postal-prefix table is the primary
|
|
803
|
+
// lookup; when shippingZones is wired AND the postal table
|
|
804
|
+
// misses, fall back to the country+region zone-router.
|
|
805
|
+
var destZone = await _resolvePostalToZone(destCountry, destPostal);
|
|
806
|
+
if (!destZone && shippingZones && typeof shippingZones.zoneForDestination === "function") {
|
|
807
|
+
destZone = await shippingZones.zoneForDestination({
|
|
808
|
+
country: destCountry,
|
|
809
|
+
region: destRegion ? destRegion.toUpperCase() : null,
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
if (!destZone) {
|
|
813
|
+
return {
|
|
814
|
+
ok: false,
|
|
815
|
+
reason: "no_destination_zone",
|
|
816
|
+
origin_location: origin,
|
|
817
|
+
origin_zone: null,
|
|
818
|
+
destination_zone: null,
|
|
819
|
+
ship_by_date: null,
|
|
820
|
+
est_min_delivery_date: null,
|
|
821
|
+
est_max_delivery_date: null,
|
|
822
|
+
service_levels: [],
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Resolve origin zone via the same postal-zone lookup keyed on
|
|
827
|
+
// the origin location's slug treated as its own zone (operators
|
|
828
|
+
// typically register `defineCarrierTransit({from_zone: "<origin>",
|
|
829
|
+
// ...})`). The convention is: when no separate origin_zone is
|
|
830
|
+
// registered, use the origin_location as the from_zone.
|
|
831
|
+
var originZone = origin;
|
|
832
|
+
|
|
833
|
+
// Compute ship_by_date — the calendar day the parcel physically
|
|
834
|
+
// leaves the origin. If `now` is before today's cutoff in the
|
|
835
|
+
// origin TZ AND today is a business day, ship today; else push
|
|
836
|
+
// to the next origin-region business day.
|
|
837
|
+
var nowWall = _wallClockIn(cutoff.timezone, now);
|
|
838
|
+
var cutoffHH = Number(cutoff.daily_cutoff_local_time.slice(0, 2));
|
|
839
|
+
var cutoffMM = Number(cutoff.daily_cutoff_local_time.slice(3, 5));
|
|
840
|
+
var beforeCut = nowWall.hh < cutoffHH || (nowWall.hh === cutoffHH && nowWall.mm < cutoffMM);
|
|
841
|
+
|
|
842
|
+
// Origin holidays — keyed by the origin's region. The operator
|
|
843
|
+
// either passes an explicit `origin_region` via inventoryLocations
|
|
844
|
+
// (when wired) or the framework reads the country halves of
|
|
845
|
+
// origin_zone slugs by convention; in the absence of either, the
|
|
846
|
+
// origin region defaults to a globally empty set (no holiday
|
|
847
|
+
// skip on the ship side). Destination holidays follow the same
|
|
848
|
+
// pattern, keyed by `destination_country` (default) OR an explicit
|
|
849
|
+
// override.
|
|
850
|
+
//
|
|
851
|
+
// The operator-facing knob is `defineHoliday({region: <slug>})`:
|
|
852
|
+
// they register against whatever region key matches the location's
|
|
853
|
+
// documented region.
|
|
854
|
+
var originRegion = await _resolveOriginRegion(origin);
|
|
855
|
+
var destRegionKey = destRegion ? destRegion : destCountry.toLowerCase();
|
|
856
|
+
var originHolidays = originRegion ? await _holidaysForRegion(originRegion) : {};
|
|
857
|
+
var destHolidays = await _holidaysForRegion(destRegionKey);
|
|
858
|
+
|
|
859
|
+
var shipBy = { y: nowWall.y, m: nowWall.m, d: nowWall.d };
|
|
860
|
+
if (!beforeCut) {
|
|
861
|
+
// On or after cutoff — push one calendar day before applying
|
|
862
|
+
// the business-day roll. Without the +1 step, a Mon-1pm
|
|
863
|
+
// request with a noon cutoff would re-land on Mon after the
|
|
864
|
+
// weekend skip (which does nothing on a weekday).
|
|
865
|
+
shipBy = _addDays(shipBy, 1);
|
|
866
|
+
}
|
|
867
|
+
shipBy = _addBusinessDays(shipBy, 0, originHolidays);
|
|
868
|
+
var shipByStr = _formatYMD(shipBy);
|
|
869
|
+
|
|
870
|
+
// Transit rows for the (origin_zone, dest_zone) pair. Filter by
|
|
871
|
+
// requested_service_level when supplied; the result is the menu
|
|
872
|
+
// of service levels the storefront renders.
|
|
873
|
+
var transits = await _liveTransitsFor(originZone, destZone, requestedSvc);
|
|
874
|
+
var serviceLevels = [];
|
|
875
|
+
var minDeliver = null;
|
|
876
|
+
var maxDeliver = null;
|
|
877
|
+
for (var i = 0; i < transits.length; i += 1) {
|
|
878
|
+
var t = transits[i];
|
|
879
|
+
var deliverParts = _addBusinessDays(shipBy, t.transit_days, destHolidays);
|
|
880
|
+
var deliverStr = _formatYMD(deliverParts);
|
|
881
|
+
serviceLevels.push({
|
|
882
|
+
code: t.service_level,
|
|
883
|
+
label: t.carrier + " " + t.service_level,
|
|
884
|
+
carrier: t.carrier,
|
|
885
|
+
ship_by: shipByStr,
|
|
886
|
+
deliver_by: deliverStr,
|
|
887
|
+
business_days: t.transit_days,
|
|
888
|
+
});
|
|
889
|
+
var deliverTs = Date.UTC(deliverParts.y, deliverParts.m - 1, deliverParts.d);
|
|
890
|
+
if (minDeliver == null || deliverTs < minDeliver.ts) minDeliver = { ts: deliverTs, str: deliverStr };
|
|
891
|
+
if (maxDeliver == null || deliverTs > maxDeliver.ts) maxDeliver = { ts: deliverTs, str: deliverStr };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
ok: serviceLevels.length > 0,
|
|
896
|
+
reason: serviceLevels.length === 0 ? "no_transit_rows" : null,
|
|
897
|
+
origin_location: origin,
|
|
898
|
+
origin_zone: originZone,
|
|
899
|
+
destination_zone: destZone,
|
|
900
|
+
ship_by_date: shipByStr,
|
|
901
|
+
est_min_delivery_date: minDeliver ? minDeliver.str : null,
|
|
902
|
+
est_max_delivery_date: maxDeliver ? maxDeliver.str : null,
|
|
903
|
+
service_levels: serviceLevels,
|
|
904
|
+
};
|
|
905
|
+
},
|
|
906
|
+
|
|
907
|
+
// Per-line cart estimates. `cart.lines` is an array; each line
|
|
908
|
+
// carries an optional `origin_location` (split-shipment case) and
|
|
909
|
+
// weight_grams. The result mirrors the line shape with an
|
|
910
|
+
// `estimate` field per line; the cart-level fields are the
|
|
911
|
+
// intersection (max of mins, max of maxes) so the storefront can
|
|
912
|
+
// render a "all items by ___" summary.
|
|
913
|
+
estimateForCart: async function (input) {
|
|
914
|
+
if (!input || typeof input !== "object") {
|
|
915
|
+
throw new TypeError("deliveryEstimate.estimateForCart: input object required");
|
|
916
|
+
}
|
|
917
|
+
var cart = input.cart;
|
|
918
|
+
if (!cart || typeof cart !== "object" || !Array.isArray(cart.lines)) {
|
|
919
|
+
throw new TypeError("deliveryEstimate.estimateForCart: cart.lines must be an array");
|
|
920
|
+
}
|
|
921
|
+
if (!input.destination || typeof input.destination !== "object") {
|
|
922
|
+
throw new TypeError("deliveryEstimate.estimateForCart: destination object required");
|
|
923
|
+
}
|
|
924
|
+
var dest = input.destination;
|
|
925
|
+
var now = input.now == null ? _now() : _epochMs(input.now, "now");
|
|
926
|
+
|
|
927
|
+
var perLine = [];
|
|
928
|
+
var slowestMin = null;
|
|
929
|
+
var slowestMax = null;
|
|
930
|
+
var anyMissing = false;
|
|
931
|
+
for (var i = 0; i < cart.lines.length; i += 1) {
|
|
932
|
+
var line = cart.lines[i];
|
|
933
|
+
if (!line || typeof line !== "object") {
|
|
934
|
+
throw new TypeError("deliveryEstimate.estimateForCart: cart.lines[" + i + "] must be an object");
|
|
935
|
+
}
|
|
936
|
+
var est = await this.estimate({
|
|
937
|
+
origin_location: line.origin_location,
|
|
938
|
+
destination_postal: dest.postal,
|
|
939
|
+
destination_country: dest.country,
|
|
940
|
+
destination_region: dest.region,
|
|
941
|
+
weight_grams: line.weight_grams,
|
|
942
|
+
requested_service_level: input.requested_service_level,
|
|
943
|
+
now: now,
|
|
944
|
+
});
|
|
945
|
+
perLine.push({ line_id: line.id, estimate: est });
|
|
946
|
+
if (!est.ok) { anyMissing = true; continue; }
|
|
947
|
+
if (slowestMin == null || est.est_min_delivery_date > slowestMin) slowestMin = est.est_min_delivery_date;
|
|
948
|
+
if (slowestMax == null || est.est_max_delivery_date > slowestMax) slowestMax = est.est_max_delivery_date;
|
|
949
|
+
}
|
|
950
|
+
return {
|
|
951
|
+
ok: !anyMissing && perLine.length > 0,
|
|
952
|
+
reason: anyMissing ? "line_unresolved" : (perLine.length === 0 ? "empty_cart" : null),
|
|
953
|
+
cart_min_delivery_date: slowestMin,
|
|
954
|
+
cart_max_delivery_date: slowestMax,
|
|
955
|
+
lines: perLine,
|
|
956
|
+
};
|
|
957
|
+
},
|
|
958
|
+
|
|
959
|
+
// Operator dashboards. Filter by carrier when supplied; otherwise
|
|
960
|
+
// returns every live transit row in (from_zone, to_zone,
|
|
961
|
+
// transit_days, carrier, service_level) order so the result is
|
|
962
|
+
// dashboard-renderable as-is.
|
|
963
|
+
listTransits: async function (listOpts) {
|
|
964
|
+
listOpts = listOpts || {};
|
|
965
|
+
var sql = "SELECT * FROM carrier_transits WHERE archived_at IS NULL";
|
|
966
|
+
var params = [];
|
|
967
|
+
if (listOpts.carrier != null) {
|
|
968
|
+
_carrier(listOpts.carrier);
|
|
969
|
+
sql += " AND carrier = ?1";
|
|
970
|
+
params.push(listOpts.carrier);
|
|
971
|
+
}
|
|
972
|
+
sql += " ORDER BY from_zone ASC, to_zone ASC, transit_days ASC, carrier ASC, service_level ASC";
|
|
973
|
+
var r = await query(sql, params);
|
|
974
|
+
var out = [];
|
|
975
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateTransit(r.rows[i]));
|
|
976
|
+
return out;
|
|
977
|
+
},
|
|
978
|
+
|
|
979
|
+
listCutoffs: async function () {
|
|
980
|
+
var r = await query(
|
|
981
|
+
"SELECT * FROM shipping_cutoffs ORDER BY origin_location ASC",
|
|
982
|
+
[],
|
|
983
|
+
);
|
|
984
|
+
var out = [];
|
|
985
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateCutoff(r.rows[i]));
|
|
986
|
+
return out;
|
|
987
|
+
},
|
|
988
|
+
|
|
989
|
+
listHolidays: async function (listOpts) {
|
|
990
|
+
listOpts = listOpts || {};
|
|
991
|
+
var where = ["archived_at IS NULL"];
|
|
992
|
+
var params = [];
|
|
993
|
+
var idx = 1;
|
|
994
|
+
if (listOpts.region != null) {
|
|
995
|
+
var region = _region(listOpts.region, "region");
|
|
996
|
+
where.push("region = ?" + idx);
|
|
997
|
+
params.push(region);
|
|
998
|
+
idx += 1;
|
|
999
|
+
}
|
|
1000
|
+
if (listOpts.from != null) {
|
|
1001
|
+
_date(listOpts.from, "from");
|
|
1002
|
+
where.push("date >= ?" + idx);
|
|
1003
|
+
params.push(listOpts.from);
|
|
1004
|
+
idx += 1;
|
|
1005
|
+
}
|
|
1006
|
+
if (listOpts.to != null) {
|
|
1007
|
+
_date(listOpts.to, "to");
|
|
1008
|
+
where.push("date <= ?" + idx);
|
|
1009
|
+
params.push(listOpts.to);
|
|
1010
|
+
idx += 1;
|
|
1011
|
+
}
|
|
1012
|
+
var sql = "SELECT * FROM shipping_holidays WHERE " + where.join(" AND ") +
|
|
1013
|
+
" ORDER BY date ASC, region ASC";
|
|
1014
|
+
var r = await query(sql, params);
|
|
1015
|
+
var out = [];
|
|
1016
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_hydrateHoliday(r.rows[i]));
|
|
1017
|
+
return out;
|
|
1018
|
+
},
|
|
1019
|
+
|
|
1020
|
+
listPostalZones: async function (listOpts) {
|
|
1021
|
+
listOpts = listOpts || {};
|
|
1022
|
+
var sql = "SELECT * FROM delivery_postal_zones";
|
|
1023
|
+
var params = [];
|
|
1024
|
+
if (listOpts.country != null) {
|
|
1025
|
+
var country = _country(listOpts.country, "country");
|
|
1026
|
+
sql += " WHERE country = ?1";
|
|
1027
|
+
params.push(country);
|
|
1028
|
+
}
|
|
1029
|
+
sql += " ORDER BY country ASC, postal_prefix ASC";
|
|
1030
|
+
var r = await query(sql, params);
|
|
1031
|
+
var out = [];
|
|
1032
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_hydratePostalZone(r.rows[i]));
|
|
1033
|
+
return out;
|
|
1034
|
+
},
|
|
1035
|
+
|
|
1036
|
+
// Soft-delete a transit row by id. Idempotent — archiving an
|
|
1037
|
+
// already-archived row returns it unchanged. Archived rows drop
|
|
1038
|
+
// out of estimate() math but stay on disk for the audit trail.
|
|
1039
|
+
archiveTransit: async function (transitId) {
|
|
1040
|
+
try { _b().guardUuid.sanitize(transitId, { profile: "strict" }); }
|
|
1041
|
+
catch (_e) { throw new TypeError("deliveryEstimate.archiveTransit: transit_id must be a valid uuid"); }
|
|
1042
|
+
var current = (await query(
|
|
1043
|
+
"SELECT * FROM carrier_transits WHERE id = ?1",
|
|
1044
|
+
[transitId],
|
|
1045
|
+
)).rows[0];
|
|
1046
|
+
if (!current) return null;
|
|
1047
|
+
if (current.archived_at != null) return _hydrateTransit(current);
|
|
1048
|
+
var ts = _now();
|
|
1049
|
+
await query(
|
|
1050
|
+
"UPDATE carrier_transits SET archived_at = ?1, updated_at = ?1 WHERE id = ?2",
|
|
1051
|
+
[ts, transitId],
|
|
1052
|
+
);
|
|
1053
|
+
var r = await query(
|
|
1054
|
+
"SELECT * FROM carrier_transits WHERE id = ?1",
|
|
1055
|
+
[transitId],
|
|
1056
|
+
);
|
|
1057
|
+
return _hydrateTransit(r.rows[0]);
|
|
1058
|
+
},
|
|
1059
|
+
|
|
1060
|
+
// Same shape as archiveTransit — soft-delete a single holiday.
|
|
1061
|
+
archiveHoliday: async function (holidayId) {
|
|
1062
|
+
try { _b().guardUuid.sanitize(holidayId, { profile: "strict" }); }
|
|
1063
|
+
catch (_e) { throw new TypeError("deliveryEstimate.archiveHoliday: holiday_id must be a valid uuid"); }
|
|
1064
|
+
var current = (await query(
|
|
1065
|
+
"SELECT * FROM shipping_holidays WHERE id = ?1",
|
|
1066
|
+
[holidayId],
|
|
1067
|
+
)).rows[0];
|
|
1068
|
+
if (!current) return null;
|
|
1069
|
+
if (current.archived_at != null) return _hydrateHoliday(current);
|
|
1070
|
+
var ts = _now();
|
|
1071
|
+
await query(
|
|
1072
|
+
"UPDATE shipping_holidays SET archived_at = ?1 WHERE id = ?2",
|
|
1073
|
+
[ts, holidayId],
|
|
1074
|
+
);
|
|
1075
|
+
var r = await query(
|
|
1076
|
+
"SELECT * FROM shipping_holidays WHERE id = ?1",
|
|
1077
|
+
[holidayId],
|
|
1078
|
+
);
|
|
1079
|
+
return _hydrateHoliday(r.rows[0]);
|
|
1080
|
+
},
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
// Helper: resolve the origin's region for holiday-skip math. Prefers
|
|
1084
|
+
// an injected inventoryLocations resolver (`regionFor(slug)`) and
|
|
1085
|
+
// falls back to null (no origin-side holidays applied). Keeping the
|
|
1086
|
+
// resolver injected leaves the holiday-region naming convention to
|
|
1087
|
+
// the operator — `us-ca` / `us` / `eu-de` are equally valid keys
|
|
1088
|
+
// depending on the operator's policy.
|
|
1089
|
+
async function _resolveOriginRegion(origin) {
|
|
1090
|
+
if (inventoryLocations && typeof inventoryLocations.regionFor === "function") {
|
|
1091
|
+
try {
|
|
1092
|
+
var r = await inventoryLocations.regionFor(origin);
|
|
1093
|
+
if (typeof r === "string" && r.length) return r;
|
|
1094
|
+
} catch (_e) {
|
|
1095
|
+
// drop-silent — by design: the holiday-region lookup is a hint,
|
|
1096
|
+
// a missing region just means no origin-side holiday skips.
|
|
1097
|
+
return null;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
return null;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
module.exports = {
|
|
1105
|
+
create: create,
|
|
1106
|
+
CARRIERS: CARRIERS,
|
|
1107
|
+
MAX_TRANSIT_DAYS: MAX_TRANSIT_DAYS,
|
|
1108
|
+
MAX_WEIGHT_GRAMS: MAX_WEIGHT_GRAMS,
|
|
1109
|
+
ZONE_RE: ZONE_RE,
|
|
1110
|
+
DATE_RE: DATE_RE,
|
|
1111
|
+
TIME_RE: TIME_RE,
|
|
1112
|
+
COUNTRY_RE: COUNTRY_RE,
|
|
1113
|
+
};
|