@blamejs/blamejs-shop 0.0.65 → 0.0.70
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 +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.dropshipForwarding
|
|
4
|
+
* @title Dropship forwarding — general-purpose drop-ship vendor
|
|
5
|
+
* binding + per-order forward-to-vendor record
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Operators selling drop-shipped goods (no inventory on hand;
|
|
9
|
+
* the vendor stocks + ships) bind each storefront SKU to a
|
|
10
|
+
* vendor `slug` + per-unit `cost_minor` (wholesale) + lead-time
|
|
11
|
+
* + return-policy snapshot. When an order containing those SKUs
|
|
12
|
+
* completes, `forwardOrder` writes one `dropship_forwardings`
|
|
13
|
+
* row per (order, vendor, SKU) so each line carries its own
|
|
14
|
+
* vendor_ref + carrier + tracking_number on the return leg —
|
|
15
|
+
* the vendor's downstream system stamps a per-line PO and the
|
|
16
|
+
* shop's worker reconciles the per-line shipment back to the
|
|
17
|
+
* customer order.
|
|
18
|
+
*
|
|
19
|
+
* Distinct from `printOnDemand` (which bundles every line from
|
|
20
|
+
* one supplier into a single row + carries supplier-product-id
|
|
21
|
+
* + supplier-variant-id + artwork-url + position for the print
|
|
22
|
+
* pipeline). This primitive is the supplier-agnostic
|
|
23
|
+
* counterpart: any vendor that fulfills physical goods on the
|
|
24
|
+
* operator's behalf can be bound, with no print-specific
|
|
25
|
+
* metadata in the way.
|
|
26
|
+
*
|
|
27
|
+
* The HTTP call to the vendor lives in the operator's worker —
|
|
28
|
+
* each vendor has its own auth shape + endpoint surface, and
|
|
29
|
+
* pinning a wire-format choice into the framework would shut
|
|
30
|
+
* out the operator whose vendor isn't yet wired. The worker
|
|
31
|
+
* reads `pendingForwardings`, calls the vendor, then calls back
|
|
32
|
+
* into this primitive's `markVendorAccepted` /
|
|
33
|
+
* `markVendorShipped` / `markVendorDelivered` /
|
|
34
|
+
* `markVendorFailed` / `markVendorReturned` to record each
|
|
35
|
+
* beat.
|
|
36
|
+
*
|
|
37
|
+
* Lifecycle (six-state FSM):
|
|
38
|
+
*
|
|
39
|
+
* queued — written by forwardOrder
|
|
40
|
+
* accepted — vendor acknowledged
|
|
41
|
+
* shipped — vendor handed to carrier (carrier + tracking)
|
|
42
|
+
* delivered — carrier confirmed delivery
|
|
43
|
+
* failed — terminal; vendor refused or aborted
|
|
44
|
+
* returned — terminal; customer returned
|
|
45
|
+
*
|
|
46
|
+
* Allowed transitions:
|
|
47
|
+
* queued -> accepted, failed
|
|
48
|
+
* accepted -> shipped, failed
|
|
49
|
+
* shipped -> delivered, failed, returned
|
|
50
|
+
* delivered -> returned
|
|
51
|
+
*
|
|
52
|
+
* Composition:
|
|
53
|
+
* - b.uuid.v7 — forwarding row PKs (sortable so audit
|
|
54
|
+
* queries sort cleanly without an extra
|
|
55
|
+
* index)
|
|
56
|
+
* - b.guardUuid — strict UUID validation on read verbs +
|
|
57
|
+
* transition entry points
|
|
58
|
+
* - b.fsm — dropship_forwarding status FSM with the
|
|
59
|
+
* transitions above
|
|
60
|
+
* - b.audit — FSM emits transition events under "fsm"
|
|
61
|
+
*
|
|
62
|
+
* Storage: `migrations-d1/0154_dropship_forwarding.sql`.
|
|
63
|
+
*
|
|
64
|
+
* @primitive dropshipForwarding
|
|
65
|
+
* @related b.fsm, b.uuid, shop.printOnDemand, shop.vendors
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
var bShop;
|
|
69
|
+
function _b() {
|
|
70
|
+
if (!bShop) bShop = require("./index");
|
|
71
|
+
return bShop.framework;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---- constants ----------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
var FORWARDING_STATUSES = Object.freeze([
|
|
77
|
+
"queued", "accepted", "shipped", "delivered", "failed", "returned",
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
// SKU shape matches the catalog + print-on-demand convention: alnum
|
|
81
|
+
// + . _ - with a 128-char ceiling. Vendor slug uses the lowercase
|
|
82
|
+
// alnum + dash convention shared with the vendors primitive (the
|
|
83
|
+
// two registries don't share a FK but they share an operator's
|
|
84
|
+
// muscle memory).
|
|
85
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
86
|
+
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
87
|
+
var CURRENCY_RE = /^[A-Z]{3}$/;
|
|
88
|
+
var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
|
|
89
|
+
|
|
90
|
+
var MAX_RETURN_POLICY_LEN = 4000;
|
|
91
|
+
var MAX_VENDOR_REF_LEN = 256;
|
|
92
|
+
var MAX_CARRIER_LEN = 64;
|
|
93
|
+
var MAX_TRACKING_LEN = 128;
|
|
94
|
+
var MAX_FAIL_REASON_LEN = 4000;
|
|
95
|
+
var MAX_ADDRESS_JSON_LEN = 8192;
|
|
96
|
+
var MAX_LEAD_TIME_DAYS = 365;
|
|
97
|
+
var MAX_LIST_LIMIT = 200;
|
|
98
|
+
var MAX_LINES = 1000;
|
|
99
|
+
|
|
100
|
+
// ---- FSM definition -----------------------------------------------------
|
|
101
|
+
|
|
102
|
+
var _dsFsm = null;
|
|
103
|
+
function _getDropshipFsm() {
|
|
104
|
+
if (_dsFsm) return _dsFsm;
|
|
105
|
+
// b.fsm emits audit events under the "fsm" namespace — register
|
|
106
|
+
// idempotently so the audit sink keeps the events instead of
|
|
107
|
+
// dropping them with a noisy warning.
|
|
108
|
+
try { _b().audit.registerNamespace("fsm"); } catch (_e) { /* idempotent — sink already knows about the namespace */ }
|
|
109
|
+
_dsFsm = _b().fsm.define({
|
|
110
|
+
name: "dropship_forwarding",
|
|
111
|
+
initial: "queued",
|
|
112
|
+
states: {
|
|
113
|
+
queued: {},
|
|
114
|
+
accepted: {},
|
|
115
|
+
shipped: {},
|
|
116
|
+
delivered: {},
|
|
117
|
+
failed: {},
|
|
118
|
+
returned: {},
|
|
119
|
+
},
|
|
120
|
+
transitions: [
|
|
121
|
+
{ from: "queued", to: "accepted", on: "accept" },
|
|
122
|
+
{ from: "queued", to: "failed", on: "fail" },
|
|
123
|
+
{ from: "accepted", to: "shipped", on: "ship" },
|
|
124
|
+
{ from: "accepted", to: "failed", on: "fail" },
|
|
125
|
+
{ from: "shipped", to: "delivered", on: "deliver" },
|
|
126
|
+
{ from: "shipped", to: "failed", on: "fail" },
|
|
127
|
+
{ from: "shipped", to: "returned", on: "return" },
|
|
128
|
+
{ from: "delivered", to: "returned", on: "return" },
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
return _dsFsm;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- validators ---------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
function _uuid(s, label) {
|
|
137
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
138
|
+
catch (e) {
|
|
139
|
+
throw new TypeError("dropship-forwarding: " + label + " — " + (e && e.message || "invalid UUID"));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function _sku(s) {
|
|
143
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
144
|
+
throw new TypeError("dropship-forwarding: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function _slug(s, label) {
|
|
148
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
149
|
+
throw new TypeError("dropship-forwarding: " + (label || "vendor_slug") +
|
|
150
|
+
" must be lowercase alnum + dash, no leading/trailing dash, 1..64 chars");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function _costMinor(n) {
|
|
154
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
155
|
+
throw new TypeError("dropship-forwarding: cost_minor must be a non-negative integer (minor units)");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function _currency(s) {
|
|
159
|
+
if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
|
|
160
|
+
throw new TypeError("dropship-forwarding: currency must be a 3-letter uppercase ISO-4217 code");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function _leadTimeDays(n) {
|
|
164
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_LEAD_TIME_DAYS) {
|
|
165
|
+
throw new TypeError("dropship-forwarding: lead_time_days must be an integer 0.." + MAX_LEAD_TIME_DAYS);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function _returnPolicy(s) {
|
|
169
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_RETURN_POLICY_LEN) {
|
|
170
|
+
throw new TypeError("dropship-forwarding: return_policy must be a non-empty string <= " + MAX_RETURN_POLICY_LEN + " chars");
|
|
171
|
+
}
|
|
172
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
173
|
+
throw new TypeError("dropship-forwarding: return_policy must not contain control bytes");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function _positiveInt(n, label) {
|
|
177
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
178
|
+
throw new TypeError("dropship-forwarding: " + label + " must be a positive integer");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function _shippingAddress(addr) {
|
|
182
|
+
if (!addr || typeof addr !== "object" || Array.isArray(addr)) {
|
|
183
|
+
throw new TypeError("dropship-forwarding: shipping_address must be a plain object");
|
|
184
|
+
}
|
|
185
|
+
if (typeof addr.country !== "string" || !/^[A-Z]{2}$/.test(addr.country)) {
|
|
186
|
+
throw new TypeError("dropship-forwarding: shipping_address.country must be a 2-letter ISO 3166-1 code (uppercase)");
|
|
187
|
+
}
|
|
188
|
+
var encoded;
|
|
189
|
+
try { encoded = JSON.stringify(addr); }
|
|
190
|
+
catch (_e) {
|
|
191
|
+
throw new TypeError("dropship-forwarding: shipping_address must be JSON-serialisable");
|
|
192
|
+
}
|
|
193
|
+
if (encoded.length > MAX_ADDRESS_JSON_LEN) {
|
|
194
|
+
throw new TypeError("dropship-forwarding: shipping_address JSON must be <= " +
|
|
195
|
+
MAX_ADDRESS_JSON_LEN + " characters serialised");
|
|
196
|
+
}
|
|
197
|
+
return encoded;
|
|
198
|
+
}
|
|
199
|
+
function _shortText(s, label, max) {
|
|
200
|
+
if (typeof s !== "string" || !s.length || s.length > max) {
|
|
201
|
+
throw new TypeError("dropship-forwarding: " + label + " must be a non-empty string <= " + max + " chars");
|
|
202
|
+
}
|
|
203
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
204
|
+
throw new TypeError("dropship-forwarding: " + label + " must not contain control bytes");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function _optTimestamp(n, label) {
|
|
208
|
+
if (n == null) return null;
|
|
209
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
210
|
+
throw new TypeError("dropship-forwarding: " + label + " must be a non-negative integer (epoch ms) when provided");
|
|
211
|
+
}
|
|
212
|
+
return n;
|
|
213
|
+
}
|
|
214
|
+
function _limit(n) {
|
|
215
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
216
|
+
throw new TypeError("dropship-forwarding: limit must be an integer in 1..." + MAX_LIST_LIMIT);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function _windowTs(n, label) {
|
|
220
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
221
|
+
throw new TypeError("dropship-forwarding: " + label + " must be a non-negative integer (epoch ms)");
|
|
222
|
+
}
|
|
223
|
+
return n;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- monotonic clock ---------------------------------------------------
|
|
227
|
+
//
|
|
228
|
+
// Operator-driven FSM transitions can land in the same millisecond
|
|
229
|
+
// on fast machines (markVendorAccepted immediately followed by
|
|
230
|
+
// markVendorShipped in a test, for instance). Bumping by 1ms on a
|
|
231
|
+
// tie keeps the timeline strictly increasing so a sort-by-occurred_at
|
|
232
|
+
// read returns the events in the order they were issued and the
|
|
233
|
+
// per-vendor metrics window contains the writes the test just made
|
|
234
|
+
// without a wall-clock race.
|
|
235
|
+
|
|
236
|
+
var _lastTs = 0;
|
|
237
|
+
function _now() {
|
|
238
|
+
var t = Date.now();
|
|
239
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
240
|
+
_lastTs = t;
|
|
241
|
+
return t;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ---- factory ------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
function create(opts) {
|
|
247
|
+
opts = opts || {};
|
|
248
|
+
var query = opts.query;
|
|
249
|
+
if (!query) {
|
|
250
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
251
|
+
}
|
|
252
|
+
// Optional handles. `vendors` lets the primitive refuse a bind
|
|
253
|
+
// against an unknown / archived vendor slug when the operator is
|
|
254
|
+
// running the vendors registry. `orderTracking` is reserved for
|
|
255
|
+
// future composition — accepted at the factory so the surface
|
|
256
|
+
// doesn't break when a downstream operator wires it for an event
|
|
257
|
+
// emission on each FSM beat.
|
|
258
|
+
var vendors = opts.vendors || null;
|
|
259
|
+
var orderTracking = opts.orderTracking || null;
|
|
260
|
+
void orderTracking;
|
|
261
|
+
|
|
262
|
+
// Hydrate a binding row into the operator-facing shape (no
|
|
263
|
+
// wire-format columns leak; the table layout IS the read shape).
|
|
264
|
+
async function _getBindingRow(sku) {
|
|
265
|
+
var r = await query("SELECT * FROM dropship_bindings WHERE sku = ?1", [sku]);
|
|
266
|
+
if (!r.rows.length) return null;
|
|
267
|
+
return r.rows[0];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function _getForwardingRow(id) {
|
|
271
|
+
var r = await query("SELECT * FROM dropship_forwardings WHERE id = ?1", [id]);
|
|
272
|
+
if (!r.rows.length) return null;
|
|
273
|
+
var row = r.rows[0];
|
|
274
|
+
row.shipping_address = row.shipping_address_json
|
|
275
|
+
? JSON.parse(row.shipping_address_json) : {};
|
|
276
|
+
return row;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Replay the FSM against the row's current status, dispatch the
|
|
280
|
+
// transition, and apply the matching column patch. Surfaces b.fsm
|
|
281
|
+
// refusal as a typed error with the original cause attached so a
|
|
282
|
+
// worker can distinguish "wrong state" from "unknown row" without
|
|
283
|
+
// string-sniffing.
|
|
284
|
+
async function _transitionForwarding(forwardingId, event, patch) {
|
|
285
|
+
var current = await _getForwardingRow(forwardingId);
|
|
286
|
+
if (!current) {
|
|
287
|
+
throw new TypeError("dropship-forwarding: forwarding " + forwardingId + " not found");
|
|
288
|
+
}
|
|
289
|
+
var fsm = _getDropshipFsm();
|
|
290
|
+
var instance = fsm.restore({
|
|
291
|
+
state: current.status,
|
|
292
|
+
history: [],
|
|
293
|
+
context: {},
|
|
294
|
+
});
|
|
295
|
+
try {
|
|
296
|
+
await instance.transition(event, null);
|
|
297
|
+
} catch (e) {
|
|
298
|
+
var err = new Error("dropship-forwarding: forwarding transition refused — " + (e && e.message || e));
|
|
299
|
+
err.code = (e && e.code) || "DROPSHIP_FORWARDING_TRANSITION_REFUSED";
|
|
300
|
+
err.cause = e;
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
var sets = ["status = ?1"];
|
|
304
|
+
var params = [instance.state];
|
|
305
|
+
var p = 2;
|
|
306
|
+
var keys = Object.keys(patch || {});
|
|
307
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
308
|
+
var k = keys[i];
|
|
309
|
+
// Each transition writes a known fixed set of columns; the
|
|
310
|
+
// patch keys come from the primitive itself, never from
|
|
311
|
+
// operator-controlled input. safeSql.assertOneOf is the
|
|
312
|
+
// belt-and-braces gate so a future patch can't slip in an
|
|
313
|
+
// operator-controlled column name.
|
|
314
|
+
_b().safeSql.assertOneOf(k, [
|
|
315
|
+
"vendor_ref", "eta", "carrier", "tracking_number",
|
|
316
|
+
"shipped_at", "delivered_at", "failed_at", "returned_at",
|
|
317
|
+
"fail_reason",
|
|
318
|
+
]);
|
|
319
|
+
sets.push(k + " = ?" + p);
|
|
320
|
+
params.push(patch[k]);
|
|
321
|
+
p += 1;
|
|
322
|
+
}
|
|
323
|
+
params.push(forwardingId);
|
|
324
|
+
await query(
|
|
325
|
+
"UPDATE dropship_forwardings SET " + sets.join(", ") + " WHERE id = ?" + p,
|
|
326
|
+
params,
|
|
327
|
+
);
|
|
328
|
+
return await _getForwardingRow(forwardingId);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
FORWARDING_STATUSES: FORWARDING_STATUSES,
|
|
333
|
+
|
|
334
|
+
bindSkuToVendor: async function (input) {
|
|
335
|
+
if (!input || typeof input !== "object") {
|
|
336
|
+
throw new TypeError("dropship-forwarding.bindSkuToVendor: input object required");
|
|
337
|
+
}
|
|
338
|
+
_sku(input.sku);
|
|
339
|
+
_slug(input.vendor_slug, "vendor_slug");
|
|
340
|
+
_costMinor(input.cost_minor);
|
|
341
|
+
_currency(input.currency);
|
|
342
|
+
_leadTimeDays(input.lead_time_days);
|
|
343
|
+
_returnPolicy(input.return_policy);
|
|
344
|
+
|
|
345
|
+
// Optional vendors-registry consistency: when the operator
|
|
346
|
+
// wires the vendors primitive at factory time, refuse a bind
|
|
347
|
+
// against an unknown or archived vendor slug. Without a
|
|
348
|
+
// registry the primitive trusts the operator-supplied slug
|
|
349
|
+
// (operators may run a flat vendor list outside the
|
|
350
|
+
// registry).
|
|
351
|
+
if (vendors && typeof vendors.getVendor === "function") {
|
|
352
|
+
var v = await vendors.getVendor(input.vendor_slug);
|
|
353
|
+
if (!v) {
|
|
354
|
+
throw new TypeError("dropship-forwarding.bindSkuToVendor: vendor_slug " +
|
|
355
|
+
JSON.stringify(input.vendor_slug) + " not found in vendors registry");
|
|
356
|
+
}
|
|
357
|
+
if (v.status === "archived") {
|
|
358
|
+
throw new TypeError("dropship-forwarding.bindSkuToVendor: vendor_slug " +
|
|
359
|
+
JSON.stringify(input.vendor_slug) + " is archived");
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Refuse rebind via bindSkuToVendor — operators that want to
|
|
364
|
+
// switch vendor for an existing SKU explicitly unbind first
|
|
365
|
+
// (a future verb; until then operators DELETE the row out of
|
|
366
|
+
// band when migrating vendors). The refusal keeps the create
|
|
367
|
+
// path single-purpose and surfaces the "switching vendor"
|
|
368
|
+
// choice explicitly.
|
|
369
|
+
var existing = await _getBindingRow(input.sku);
|
|
370
|
+
if (existing) {
|
|
371
|
+
throw new TypeError("dropship-forwarding.bindSkuToVendor: sku " +
|
|
372
|
+
JSON.stringify(input.sku) + " already bound to " + JSON.stringify(existing.vendor_slug) +
|
|
373
|
+
" — DELETE the binding row before rebinding");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
var ts = _now();
|
|
377
|
+
await query(
|
|
378
|
+
"INSERT INTO dropship_bindings (sku, vendor_slug, cost_minor, currency, " +
|
|
379
|
+
"lead_time_days, return_policy, archived_at, created_at, updated_at) " +
|
|
380
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
|
|
381
|
+
[
|
|
382
|
+
input.sku, input.vendor_slug, input.cost_minor, input.currency,
|
|
383
|
+
input.lead_time_days, input.return_policy, ts,
|
|
384
|
+
],
|
|
385
|
+
);
|
|
386
|
+
return await _getBindingRow(input.sku);
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
// Write one forwarding row per (order, vendor, SKU). Lines
|
|
390
|
+
// whose SKU has no binding are returned on `unbound_skus` and
|
|
391
|
+
// do NOT create a row — the operator's pre-flight is expected
|
|
392
|
+
// to refuse the checkout in that case; this primitive is
|
|
393
|
+
// defensive in case it slips through. Lines whose binding is
|
|
394
|
+
// archived are surfaced separately on `archived_skus`.
|
|
395
|
+
forwardOrder: async function (input) {
|
|
396
|
+
if (!input || typeof input !== "object") {
|
|
397
|
+
throw new TypeError("dropship-forwarding.forwardOrder: input object required");
|
|
398
|
+
}
|
|
399
|
+
_uuid(input.order_id, "order_id");
|
|
400
|
+
if (!Array.isArray(input.lines) || input.lines.length === 0) {
|
|
401
|
+
throw new TypeError("dropship-forwarding.forwardOrder: lines must be a non-empty array");
|
|
402
|
+
}
|
|
403
|
+
if (input.lines.length > MAX_LINES) {
|
|
404
|
+
throw new TypeError("dropship-forwarding.forwardOrder: lines must contain <= " + MAX_LINES + " entries");
|
|
405
|
+
}
|
|
406
|
+
// Each line carries its own shipping address (drop-ship
|
|
407
|
+
// commonly splits across customers — gift-with-purchase,
|
|
408
|
+
// multi-recipient orders). The line-level address is the
|
|
409
|
+
// forwarding row's binding; if the operator wants every line
|
|
410
|
+
// to share the same address, they pass the same object on
|
|
411
|
+
// every line.
|
|
412
|
+
var normalized = [];
|
|
413
|
+
for (var i = 0; i < input.lines.length; i += 1) {
|
|
414
|
+
var l = input.lines[i];
|
|
415
|
+
if (!l || typeof l !== "object") {
|
|
416
|
+
throw new TypeError("dropship-forwarding.forwardOrder: lines[" + i + "] must be an object");
|
|
417
|
+
}
|
|
418
|
+
_sku(l.sku);
|
|
419
|
+
_positiveInt(l.quantity, "lines[" + i + "].quantity");
|
|
420
|
+
var encoded = _shippingAddress(l.shipping_address);
|
|
421
|
+
normalized.push({ sku: l.sku, quantity: l.quantity, address_json: encoded });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
var forwardingIds = [];
|
|
425
|
+
var unbound = [];
|
|
426
|
+
var archived = [];
|
|
427
|
+
for (var j = 0; j < normalized.length; j += 1) {
|
|
428
|
+
var line = normalized[j];
|
|
429
|
+
var binding = await _getBindingRow(line.sku);
|
|
430
|
+
if (!binding) { unbound.push(line.sku); continue; }
|
|
431
|
+
if (binding.archived_at != null) { archived.push(line.sku); continue; }
|
|
432
|
+
|
|
433
|
+
var id = _b().uuid.v7();
|
|
434
|
+
var ts = _now();
|
|
435
|
+
await query(
|
|
436
|
+
"INSERT INTO dropship_forwardings (id, order_id, vendor_slug, sku, quantity, " +
|
|
437
|
+
"shipping_address_json, status, occurred_at) " +
|
|
438
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'queued', ?7)",
|
|
439
|
+
[
|
|
440
|
+
id, input.order_id, binding.vendor_slug, line.sku, line.quantity,
|
|
441
|
+
line.address_json, ts,
|
|
442
|
+
],
|
|
443
|
+
);
|
|
444
|
+
forwardingIds.push(id);
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
forwarding_ids: forwardingIds,
|
|
448
|
+
unbound_skus: unbound,
|
|
449
|
+
archived_skus: archived,
|
|
450
|
+
};
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
markVendorAccepted: async function (input) {
|
|
454
|
+
if (!input || typeof input !== "object") {
|
|
455
|
+
throw new TypeError("dropship-forwarding.markVendorAccepted: input object required");
|
|
456
|
+
}
|
|
457
|
+
_uuid(input.forwarding_id, "forwarding_id");
|
|
458
|
+
_shortText(input.vendor_ref, "vendor_ref", MAX_VENDOR_REF_LEN);
|
|
459
|
+
var eta = _optTimestamp(input.eta, "eta");
|
|
460
|
+
var patch = { vendor_ref: input.vendor_ref };
|
|
461
|
+
if (eta != null) patch.eta = eta;
|
|
462
|
+
return await _transitionForwarding(input.forwarding_id, "accept", patch);
|
|
463
|
+
},
|
|
464
|
+
|
|
465
|
+
markVendorShipped: async function (input) {
|
|
466
|
+
if (!input || typeof input !== "object") {
|
|
467
|
+
throw new TypeError("dropship-forwarding.markVendorShipped: input object required");
|
|
468
|
+
}
|
|
469
|
+
_uuid(input.forwarding_id, "forwarding_id");
|
|
470
|
+
_shortText(input.carrier, "carrier", MAX_CARRIER_LEN);
|
|
471
|
+
_shortText(input.tracking_number, "tracking_number", MAX_TRACKING_LEN);
|
|
472
|
+
var shippedAt = _optTimestamp(input.shipped_at, "shipped_at");
|
|
473
|
+
if (shippedAt == null) shippedAt = _now();
|
|
474
|
+
return await _transitionForwarding(input.forwarding_id, "ship", {
|
|
475
|
+
carrier: input.carrier,
|
|
476
|
+
tracking_number: input.tracking_number,
|
|
477
|
+
shipped_at: shippedAt,
|
|
478
|
+
});
|
|
479
|
+
},
|
|
480
|
+
|
|
481
|
+
markVendorDelivered: async function (input) {
|
|
482
|
+
if (!input || typeof input !== "object") {
|
|
483
|
+
throw new TypeError("dropship-forwarding.markVendorDelivered: input object required");
|
|
484
|
+
}
|
|
485
|
+
_uuid(input.forwarding_id, "forwarding_id");
|
|
486
|
+
var deliveredAt = _optTimestamp(input.delivered_at, "delivered_at");
|
|
487
|
+
if (deliveredAt == null) deliveredAt = _now();
|
|
488
|
+
return await _transitionForwarding(input.forwarding_id, "deliver", {
|
|
489
|
+
delivered_at: deliveredAt,
|
|
490
|
+
});
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
markVendorFailed: async function (input) {
|
|
494
|
+
if (!input || typeof input !== "object") {
|
|
495
|
+
throw new TypeError("dropship-forwarding.markVendorFailed: input object required");
|
|
496
|
+
}
|
|
497
|
+
_uuid(input.forwarding_id, "forwarding_id");
|
|
498
|
+
_shortText(input.reason, "reason", MAX_FAIL_REASON_LEN);
|
|
499
|
+
return await _transitionForwarding(input.forwarding_id, "fail", {
|
|
500
|
+
fail_reason: input.reason,
|
|
501
|
+
failed_at: _now(),
|
|
502
|
+
});
|
|
503
|
+
},
|
|
504
|
+
|
|
505
|
+
markVendorReturned: async function (input) {
|
|
506
|
+
if (!input || typeof input !== "object") {
|
|
507
|
+
throw new TypeError("dropship-forwarding.markVendorReturned: input object required");
|
|
508
|
+
}
|
|
509
|
+
_uuid(input.forwarding_id, "forwarding_id");
|
|
510
|
+
var returnedAt = _optTimestamp(input.returned_at, "returned_at");
|
|
511
|
+
if (returnedAt == null) returnedAt = _now();
|
|
512
|
+
return await _transitionForwarding(input.forwarding_id, "return", {
|
|
513
|
+
returned_at: returnedAt,
|
|
514
|
+
});
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
getForwarding: async function (id) {
|
|
518
|
+
_uuid(id, "forwarding_id");
|
|
519
|
+
return await _getForwardingRow(id);
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
// All forwardings for one order, ordered by occurred_at ASC
|
|
523
|
+
// (the order the per-vendor rows were written). Operators
|
|
524
|
+
// rendering an order detail page get the vendor-by-vendor
|
|
525
|
+
// breakdown in the order the forward fired.
|
|
526
|
+
forwardingsForOrder: async function (orderId) {
|
|
527
|
+
_uuid(orderId, "order_id");
|
|
528
|
+
var rows = (await query(
|
|
529
|
+
"SELECT * FROM dropship_forwardings WHERE order_id = ?1 " +
|
|
530
|
+
"ORDER BY occurred_at ASC, id ASC",
|
|
531
|
+
[orderId],
|
|
532
|
+
)).rows;
|
|
533
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
534
|
+
rows[i].shipping_address = rows[i].shipping_address_json
|
|
535
|
+
? JSON.parse(rows[i].shipping_address_json) : {};
|
|
536
|
+
}
|
|
537
|
+
return rows;
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
// Worker queue drain: queued + accepted rows in FIFO order so
|
|
541
|
+
// the longest-waiting forwarding goes first. Optional
|
|
542
|
+
// vendor_slug filter narrows to one vendor's worker. `accepted`
|
|
543
|
+
// is included because an operator's tracking-polling worker
|
|
544
|
+
// wants to revisit rows that the vendor acknowledged but
|
|
545
|
+
// hasn't yet shipped — once a row hits shipped it falls off
|
|
546
|
+
// the pending queue (the carrier-side tracking takes over).
|
|
547
|
+
pendingForwardings: async function (listOpts) {
|
|
548
|
+
listOpts = listOpts || {};
|
|
549
|
+
var vendorSlug = null;
|
|
550
|
+
if (listOpts.vendor_slug != null) {
|
|
551
|
+
_slug(listOpts.vendor_slug, "vendor_slug");
|
|
552
|
+
vendorSlug = listOpts.vendor_slug;
|
|
553
|
+
}
|
|
554
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
555
|
+
_limit(limit);
|
|
556
|
+
|
|
557
|
+
var sql, params;
|
|
558
|
+
if (vendorSlug) {
|
|
559
|
+
sql = "SELECT * FROM dropship_forwardings " +
|
|
560
|
+
"WHERE status IN ('queued', 'accepted') AND vendor_slug = ?1 " +
|
|
561
|
+
"ORDER BY occurred_at ASC, id ASC LIMIT ?2";
|
|
562
|
+
params = [vendorSlug, limit];
|
|
563
|
+
} else {
|
|
564
|
+
sql = "SELECT * FROM dropship_forwardings " +
|
|
565
|
+
"WHERE status IN ('queued', 'accepted') " +
|
|
566
|
+
"ORDER BY occurred_at ASC, id ASC LIMIT ?1";
|
|
567
|
+
params = [limit];
|
|
568
|
+
}
|
|
569
|
+
var rows = (await query(sql, params)).rows;
|
|
570
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
571
|
+
rows[i].shipping_address = rows[i].shipping_address_json
|
|
572
|
+
? JSON.parse(rows[i].shipping_address_json) : {};
|
|
573
|
+
}
|
|
574
|
+
return rows;
|
|
575
|
+
},
|
|
576
|
+
|
|
577
|
+
// Per-vendor performance metrics across a closed [from, to)
|
|
578
|
+
// window. Returns counts by terminal status + the average
|
|
579
|
+
// fulfillment latency (queued occurred_at -> shipped_at) for
|
|
580
|
+
// every shipped forwarding in the window. Operators read this
|
|
581
|
+
// to drive vendor scorecards: "this vendor shipped 87% of
|
|
582
|
+
// orders in the window with a median of 2.1 days from queue
|
|
583
|
+
// to shipment."
|
|
584
|
+
//
|
|
585
|
+
// `from` and `to` are epoch-ms ints; the window is [from, to)
|
|
586
|
+
// against `occurred_at` (the queued-at stamp). A forwarding
|
|
587
|
+
// that's still queued at `to` counts toward `queued`; one that
|
|
588
|
+
// shipped before `to` counts toward `shipped` regardless of
|
|
589
|
+
// when shipped_at fired (the audit grain is the queued event).
|
|
590
|
+
metricsForVendor: async function (input) {
|
|
591
|
+
if (!input || typeof input !== "object") {
|
|
592
|
+
throw new TypeError("dropship-forwarding.metricsForVendor: input object required");
|
|
593
|
+
}
|
|
594
|
+
_slug(input.slug, "slug");
|
|
595
|
+
var from = _windowTs(input.from, "from");
|
|
596
|
+
var to = _windowTs(input.to, "to");
|
|
597
|
+
if (to <= from) {
|
|
598
|
+
throw new TypeError("dropship-forwarding.metricsForVendor: to must be > from");
|
|
599
|
+
}
|
|
600
|
+
var rows = (await query(
|
|
601
|
+
"SELECT status, occurred_at, shipped_at FROM dropship_forwardings " +
|
|
602
|
+
"WHERE vendor_slug = ?1 AND occurred_at >= ?2 AND occurred_at < ?3",
|
|
603
|
+
[input.slug, from, to],
|
|
604
|
+
)).rows;
|
|
605
|
+
|
|
606
|
+
var counts = {
|
|
607
|
+
queued: 0, accepted: 0, shipped: 0,
|
|
608
|
+
delivered: 0, failed: 0, returned: 0,
|
|
609
|
+
};
|
|
610
|
+
var shippedCount = 0;
|
|
611
|
+
var totalLatencyMs = 0;
|
|
612
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
613
|
+
var r = rows[i];
|
|
614
|
+
if (Object.prototype.hasOwnProperty.call(counts, r.status)) {
|
|
615
|
+
counts[r.status] += 1;
|
|
616
|
+
}
|
|
617
|
+
// Shipped-or-better rows that carry a shipped_at stamp
|
|
618
|
+
// contribute to the latency average. The audit grain is
|
|
619
|
+
// the queued -> shipped delta — once the carrier has the
|
|
620
|
+
// package the vendor's responsibility is discharged.
|
|
621
|
+
if (r.shipped_at != null && r.shipped_at >= r.occurred_at) {
|
|
622
|
+
shippedCount += 1;
|
|
623
|
+
totalLatencyMs += (r.shipped_at - r.occurred_at);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
slug: input.slug,
|
|
628
|
+
from: from,
|
|
629
|
+
to: to,
|
|
630
|
+
total: rows.length,
|
|
631
|
+
counts: counts,
|
|
632
|
+
avg_ship_latency_ms: shippedCount > 0 ? Math.floor(totalLatencyMs / shippedCount) : null,
|
|
633
|
+
shipped_for_latency: shippedCount,
|
|
634
|
+
};
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
module.exports = {
|
|
640
|
+
create: create,
|
|
641
|
+
FORWARDING_STATUSES: FORWARDING_STATUSES,
|
|
642
|
+
// Exposed so the test suite can assert the FSM shape without
|
|
643
|
+
// re-deriving the definition from the transitions table.
|
|
644
|
+
_getDropshipFsm: _getDropshipFsm,
|
|
645
|
+
};
|