@blamejs/blamejs-shop 0.0.62 → 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 +6 -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/compliance-export.js +614 -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/error-log.js +525 -0
- package/lib/index.js +25 -0
- package/lib/invoice-renderer.js +618 -0
- package/lib/live-chat.js +714 -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/store-credit.js +565 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.splitShipments
|
|
4
|
+
* @title Split shipments — plan-then-execute multi-parcel fulfillment
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* One order, N parcels. A three-line order with one in-stock SKU
|
|
8
|
+
* plus two backordered SKUs splits into a "ship now" parcel for
|
|
9
|
+
* the in-stock line and a "ship later" parcel for the backordered
|
|
10
|
+
* ones; the customer sees per-parcel tracking through the existing
|
|
11
|
+
* `orderTracking` primitive (which already supports multiple
|
|
12
|
+
* shipments per order — migration 0021).
|
|
13
|
+
*
|
|
14
|
+
* This primitive owns the PLAN — the proposed grouping of
|
|
15
|
+
* `order_lines` rows into parcels, the strategy that derived the
|
|
16
|
+
* plan, and the executed/cancelled lifecycle so the operator can
|
|
17
|
+
* audit why an order was split the way it was.
|
|
18
|
+
*
|
|
19
|
+
* Verbs:
|
|
20
|
+
* planSplit({ order_id, strategy?, strategyOpts?, manualPlan? })
|
|
21
|
+
* — pure read: walks order_lines + (backorder | inventoryLocations
|
|
22
|
+
* | vendors) and returns the proposed `{ id, order_id,
|
|
23
|
+
* strategy, shipments: [{ rationale, lines: [...] }] }`.
|
|
24
|
+
* Writes a `proposed` row so the plan can be referenced by
|
|
25
|
+
* id when the operator confirms via executeSplit.
|
|
26
|
+
* executeSplit({ order_id, plan, carrier? })
|
|
27
|
+
* — walks `plan.shipments` and writes one `shipments` row per
|
|
28
|
+
* parcel via the injected `orderTracking` primitive. Flips
|
|
29
|
+
* the plan row to `executed`, stores the shipment id list.
|
|
30
|
+
* Refuses if the plan is not in `proposed` status.
|
|
31
|
+
* mergeShipments({ source_shipment_ids, target_shipment_id })
|
|
32
|
+
* — operator override: re-parents the executed plan so two
|
|
33
|
+
* parcels become one. Stitches the shipment_events ledger
|
|
34
|
+
* together by re-pointing rows; the source `shipments` rows
|
|
35
|
+
* are left in the table flagged `cancelled` on the per-row
|
|
36
|
+
* status so the audit trail survives.
|
|
37
|
+
* splitsForOrder(order_id)
|
|
38
|
+
* — reads the executed (or proposed) plan(s) for an order.
|
|
39
|
+
* recommendStrategy(order_id)
|
|
40
|
+
* — heuristic: inspects the order's lines + (backorder /
|
|
41
|
+
* inventoryLocations / vendors) signals and returns the
|
|
42
|
+
* best strategy for the operator to apply.
|
|
43
|
+
*
|
|
44
|
+
* Strategies:
|
|
45
|
+
* availability — split in-stock vs backordered lines.
|
|
46
|
+
* Requires the `backorder` primitive to be wired
|
|
47
|
+
* in the factory.
|
|
48
|
+
* location — split by source location. Walks the routing
|
|
49
|
+
* strategy via inventoryLocations.routeOrder and
|
|
50
|
+
* groups lines by the resolved `location_code`.
|
|
51
|
+
* Requires `inventoryLocations` to be wired.
|
|
52
|
+
* vendor — one parcel per vendor that owns one of the
|
|
53
|
+
* SKUs. Requires `vendors` to be wired and each
|
|
54
|
+
* SKU to be assigned to at most one vendor.
|
|
55
|
+
* manual — operator supplies `manualPlan: [{ lines: [...],
|
|
56
|
+
* rationale? }]`. The primitive validates the
|
|
57
|
+
* shape (every line_id belongs to the order; the
|
|
58
|
+
* per-line qty sums to the order_line's qty) and
|
|
59
|
+
* stores it verbatim.
|
|
60
|
+
*
|
|
61
|
+
* Composition:
|
|
62
|
+
* - b.guardUuid — every order_id / shipment_id / plan_id
|
|
63
|
+
* is UUID-shape validated at the entry
|
|
64
|
+
* point.
|
|
65
|
+
* - b.uuid.v7 — split_shipment_plans.id (sortable; reads
|
|
66
|
+
* sort newest-first).
|
|
67
|
+
* - order (optional) — when wired, the factory pulls order_lines
|
|
68
|
+
* through `order.get(...)`; tests inject a
|
|
69
|
+
* lightweight stand-in.
|
|
70
|
+
* - orderTracking — executeSplit composes
|
|
71
|
+
* `orderTracking.createShipment` for each
|
|
72
|
+
* parcel. Required at the factory.
|
|
73
|
+
* - backorder — required for the `availability` strategy.
|
|
74
|
+
* The primitive composes
|
|
75
|
+
* `backorder.pendingForSku` to classify
|
|
76
|
+
* each line.
|
|
77
|
+
* - inventoryLocations — required for the `location` strategy.
|
|
78
|
+
* The primitive composes
|
|
79
|
+
* `inventoryLocations.routeOrder` and
|
|
80
|
+
* groups by `location_code`.
|
|
81
|
+
* - vendors — required for the `vendor` strategy. The
|
|
82
|
+
* primitive composes `vendors.vendorForSku`
|
|
83
|
+
* per line.
|
|
84
|
+
*
|
|
85
|
+
* Three-tier input validation: every public verb is a defensive
|
|
86
|
+
* request-shape reader or a config-time entry point — both throw
|
|
87
|
+
* on bad input. No drop-silent hot-path sinks.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
var bShop;
|
|
91
|
+
function _b() {
|
|
92
|
+
if (!bShop) bShop = require("./index");
|
|
93
|
+
return bShop.framework;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- constants ----------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
var STRATEGIES = Object.freeze([
|
|
99
|
+
"availability",
|
|
100
|
+
"location",
|
|
101
|
+
"vendor",
|
|
102
|
+
"manual",
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
var STATUSES = Object.freeze([
|
|
106
|
+
"proposed",
|
|
107
|
+
"executed",
|
|
108
|
+
"cancelled",
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
var MAX_RATIONALE_LEN = 256;
|
|
112
|
+
var MAX_LINES_PER_PARCEL = 1000;
|
|
113
|
+
var MAX_PARCELS = 100;
|
|
114
|
+
|
|
115
|
+
// ---- validators ---------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
function _uuid(s, label) {
|
|
118
|
+
try {
|
|
119
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
120
|
+
} catch (e) {
|
|
121
|
+
throw new TypeError("split-shipments: " + label + " — " + (e && e.message || "invalid UUID"));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function _strategy(s) {
|
|
126
|
+
if (typeof s !== "string" || STRATEGIES.indexOf(s) === -1) {
|
|
127
|
+
throw new TypeError("split-shipments: strategy must be one of " +
|
|
128
|
+
STRATEGIES.join(", ") + ", got " + JSON.stringify(s));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _positiveInt(n, label) {
|
|
133
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
134
|
+
throw new TypeError("split-shipments: " + label + " must be a positive integer");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function _shortText(s, label, max) {
|
|
139
|
+
if (s == null) return "";
|
|
140
|
+
if (typeof s !== "string" || s.length > max) {
|
|
141
|
+
throw new TypeError("split-shipments: " + label + " must be a string ≤ " + max + " chars");
|
|
142
|
+
}
|
|
143
|
+
return s;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _now() { return Date.now(); }
|
|
147
|
+
|
|
148
|
+
// ---- factory ------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
function create(opts) {
|
|
151
|
+
opts = opts || {};
|
|
152
|
+
var query = opts.query;
|
|
153
|
+
if (!query) {
|
|
154
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
155
|
+
}
|
|
156
|
+
// orderTracking is the only required composition — executeSplit
|
|
157
|
+
// cannot write shipment rows without it, and the primitive is
|
|
158
|
+
// useless if executeSplit can never run.
|
|
159
|
+
if (!opts.orderTracking || typeof opts.orderTracking.createShipment !== "function") {
|
|
160
|
+
throw new TypeError("split-shipments.create: opts.orderTracking with createShipment() required");
|
|
161
|
+
}
|
|
162
|
+
var orderTracking = opts.orderTracking;
|
|
163
|
+
|
|
164
|
+
// order is optional — when wired, planSplit pulls order_lines via
|
|
165
|
+
// `order.get(...)`. When absent, the primitive reads order_lines
|
|
166
|
+
// directly via SQL. The injectable seam lets tests use a lightweight
|
|
167
|
+
// stand-in without binding to the FSM.
|
|
168
|
+
var orderPrim = opts.order || null;
|
|
169
|
+
if (orderPrim && typeof orderPrim.get !== "function") {
|
|
170
|
+
throw new TypeError("split-shipments.create: opts.order must expose a get(id) method");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Strategy-specific dependencies — validated lazily inside the
|
|
174
|
+
// strategy branch so an operator that only ever uses `manual` or
|
|
175
|
+
// `availability` doesn't have to wire vendors / inventoryLocations.
|
|
176
|
+
var backorder = opts.backorder || null;
|
|
177
|
+
if (backorder && typeof backorder.pendingForSku !== "function") {
|
|
178
|
+
throw new TypeError("split-shipments.create: opts.backorder must expose pendingForSku(sku)");
|
|
179
|
+
}
|
|
180
|
+
var inventoryLocations = opts.inventoryLocations || null;
|
|
181
|
+
if (inventoryLocations && typeof inventoryLocations.routeOrder !== "function") {
|
|
182
|
+
throw new TypeError("split-shipments.create: opts.inventoryLocations must expose routeOrder(input)");
|
|
183
|
+
}
|
|
184
|
+
var vendors = opts.vendors || null;
|
|
185
|
+
if (vendors && typeof vendors.vendorForSku !== "function") {
|
|
186
|
+
throw new TypeError("split-shipments.create: opts.vendors must expose vendorForSku(sku)");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Read order_lines via the injected order primitive when wired,
|
|
190
|
+
// otherwise fall back to a direct SQL read. Returns the array of
|
|
191
|
+
// `{ id, sku, qty, ... }` rows — the same shape both code paths
|
|
192
|
+
// produce.
|
|
193
|
+
async function _orderLines(orderId) {
|
|
194
|
+
if (orderPrim) {
|
|
195
|
+
var o = await orderPrim.get(orderId);
|
|
196
|
+
if (!o) return null;
|
|
197
|
+
return o.lines || [];
|
|
198
|
+
}
|
|
199
|
+
var head = await query("SELECT id FROM orders WHERE id = ?1", [orderId]);
|
|
200
|
+
if (!head.rows.length) return null;
|
|
201
|
+
var r = await query(
|
|
202
|
+
"SELECT * FROM order_lines WHERE order_id = ?1 ORDER BY id ASC",
|
|
203
|
+
[orderId],
|
|
204
|
+
);
|
|
205
|
+
return r.rows;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---- strategy: availability ------------------------------------------
|
|
209
|
+
//
|
|
210
|
+
// Walks the order_lines and asks `backorder.pendingForSku(sku)`
|
|
211
|
+
// whether a pending backorder line for THIS order exists for the
|
|
212
|
+
// SKU. Pending → ship-later parcel; otherwise → ship-now parcel.
|
|
213
|
+
// The strategy keeps both parcels even when one is empty so the
|
|
214
|
+
// operator-facing rationale stays consistent (a single-line
|
|
215
|
+
// all-in-stock order returns one parcel; a single-line all-
|
|
216
|
+
// backordered order returns one parcel — never an empty group).
|
|
217
|
+
async function _planAvailability(orderId, lines) {
|
|
218
|
+
if (!backorder) {
|
|
219
|
+
throw new TypeError("split-shipments.planSplit(availability): opts.backorder required");
|
|
220
|
+
}
|
|
221
|
+
var shipNow = [];
|
|
222
|
+
var shipLater = [];
|
|
223
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
224
|
+
var line = lines[i];
|
|
225
|
+
var pending = await backorder.pendingForSku(line.sku);
|
|
226
|
+
var isBackordered = false;
|
|
227
|
+
for (var j = 0; j < pending.length; j += 1) {
|
|
228
|
+
if (pending[j].order_id === orderId) { isBackordered = true; break; }
|
|
229
|
+
}
|
|
230
|
+
if (isBackordered) {
|
|
231
|
+
shipLater.push({ line_id: line.id, qty: line.qty });
|
|
232
|
+
} else {
|
|
233
|
+
shipNow.push({ line_id: line.id, qty: line.qty });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
var parcels = [];
|
|
237
|
+
if (shipNow.length) {
|
|
238
|
+
parcels.push({ rationale: "in_stock", lines: shipNow });
|
|
239
|
+
}
|
|
240
|
+
if (shipLater.length) {
|
|
241
|
+
parcels.push({ rationale: "backordered", lines: shipLater });
|
|
242
|
+
}
|
|
243
|
+
return parcels;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---- strategy: location ----------------------------------------------
|
|
247
|
+
//
|
|
248
|
+
// Hands the lines off to `inventoryLocations.routeOrder` and
|
|
249
|
+
// re-groups the resulting allocation by location_code. Lines that
|
|
250
|
+
// land on `unfulfillable` form their own parcel tagged
|
|
251
|
+
// `unfulfillable` so the operator sees the gap rather than a
|
|
252
|
+
// silently-dropped quantity.
|
|
253
|
+
async function _planLocation(orderId, lines, strategyOpts) {
|
|
254
|
+
if (!inventoryLocations) {
|
|
255
|
+
throw new TypeError("split-shipments.planSplit(location): opts.inventoryLocations required");
|
|
256
|
+
}
|
|
257
|
+
// Build the line_id lookup so the routing result (SKU+qty) can
|
|
258
|
+
// be mapped back to the order_line row. Multiple order_lines
|
|
259
|
+
// CAN share a SKU (rare but legal); the first unconsumed line
|
|
260
|
+
// for the SKU wins on each allocation step.
|
|
261
|
+
var bySku = {};
|
|
262
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
263
|
+
var l = lines[i];
|
|
264
|
+
if (!bySku[l.sku]) bySku[l.sku] = [];
|
|
265
|
+
bySku[l.sku].push({ line_id: l.id, remaining: l.qty });
|
|
266
|
+
}
|
|
267
|
+
var routeInput = {
|
|
268
|
+
lines: lines.map(function (line) { return { sku: line.sku, quantity: line.qty }; }),
|
|
269
|
+
};
|
|
270
|
+
if (strategyOpts && strategyOpts.routing_strategy) {
|
|
271
|
+
routeInput.strategy = strategyOpts.routing_strategy;
|
|
272
|
+
}
|
|
273
|
+
if (strategyOpts && strategyOpts.routing_strategyOpts) {
|
|
274
|
+
routeInput.strategyOpts = strategyOpts.routing_strategyOpts;
|
|
275
|
+
}
|
|
276
|
+
var routed = await inventoryLocations.routeOrder(routeInput);
|
|
277
|
+
var parcels = [];
|
|
278
|
+
for (var a = 0; a < routed.allocation.length; a += 1) {
|
|
279
|
+
var alloc = routed.allocation[a];
|
|
280
|
+
var parcelLines = [];
|
|
281
|
+
for (var b = 0; b < alloc.lines.length; b += 1) {
|
|
282
|
+
var pick = alloc.lines[b];
|
|
283
|
+
var remaining = pick.quantity;
|
|
284
|
+
var queue = bySku[pick.sku] || [];
|
|
285
|
+
while (remaining > 0 && queue.length) {
|
|
286
|
+
var head = queue[0];
|
|
287
|
+
var take = Math.min(head.remaining, remaining);
|
|
288
|
+
parcelLines.push({ line_id: head.line_id, qty: take });
|
|
289
|
+
head.remaining -= take;
|
|
290
|
+
remaining -= take;
|
|
291
|
+
if (head.remaining === 0) queue.shift();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (parcelLines.length) {
|
|
295
|
+
parcels.push({ rationale: "location:" + alloc.location_code, lines: parcelLines });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (routed.unfulfillable && routed.unfulfillable.length) {
|
|
299
|
+
var unfulLines = [];
|
|
300
|
+
for (var u = 0; u < routed.unfulfillable.length; u += 1) {
|
|
301
|
+
var miss = routed.unfulfillable[u];
|
|
302
|
+
var qremain = miss.quantity;
|
|
303
|
+
var q2 = bySku[miss.sku] || [];
|
|
304
|
+
while (qremain > 0 && q2.length) {
|
|
305
|
+
var h2 = q2[0];
|
|
306
|
+
var t2 = Math.min(h2.remaining, qremain);
|
|
307
|
+
unfulLines.push({ line_id: h2.line_id, qty: t2 });
|
|
308
|
+
h2.remaining -= t2;
|
|
309
|
+
qremain -= t2;
|
|
310
|
+
if (h2.remaining === 0) q2.shift();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (unfulLines.length) {
|
|
314
|
+
parcels.push({ rationale: "unfulfillable", lines: unfulLines });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Used to silence the unused-orderId warning — the parameter is
|
|
318
|
+
// part of the strategy contract even when only the lines drive
|
|
319
|
+
// the routing call. Treat it as a defensive hand-off so future
|
|
320
|
+
// strategies (per-order routing-overrides table) can read it.
|
|
321
|
+
void orderId;
|
|
322
|
+
return parcels;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---- strategy: vendor ------------------------------------------------
|
|
326
|
+
//
|
|
327
|
+
// One parcel per vendor that owns one of the SKUs. SKUs with no
|
|
328
|
+
// assigned vendor land on a `vendor:unassigned` parcel so the
|
|
329
|
+
// operator can route them by hand. Parcel order is deterministic:
|
|
330
|
+
// vendor parcels sorted by slug, unassigned parcel last.
|
|
331
|
+
async function _planVendor(orderId, lines) {
|
|
332
|
+
if (!vendors) {
|
|
333
|
+
throw new TypeError("split-shipments.planSplit(vendor): opts.vendors required");
|
|
334
|
+
}
|
|
335
|
+
var byVendor = {};
|
|
336
|
+
var unassigned = [];
|
|
337
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
338
|
+
var line = lines[i];
|
|
339
|
+
var v = await vendors.vendorForSku(line.sku);
|
|
340
|
+
if (v && v.slug) {
|
|
341
|
+
if (!byVendor[v.slug]) byVendor[v.slug] = [];
|
|
342
|
+
byVendor[v.slug].push({ line_id: line.id, qty: line.qty });
|
|
343
|
+
} else {
|
|
344
|
+
unassigned.push({ line_id: line.id, qty: line.qty });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
var slugs = Object.keys(byVendor).sort();
|
|
348
|
+
var parcels = [];
|
|
349
|
+
for (var s = 0; s < slugs.length; s += 1) {
|
|
350
|
+
parcels.push({ rationale: "vendor:" + slugs[s], lines: byVendor[slugs[s]] });
|
|
351
|
+
}
|
|
352
|
+
if (unassigned.length) {
|
|
353
|
+
parcels.push({ rationale: "vendor:unassigned", lines: unassigned });
|
|
354
|
+
}
|
|
355
|
+
void orderId;
|
|
356
|
+
return parcels;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---- strategy: manual ------------------------------------------------
|
|
360
|
+
//
|
|
361
|
+
// Operator hands the primitive a pre-grouped plan. The strategy
|
|
362
|
+
// validates that:
|
|
363
|
+
// - every line_id references a real order_line for THIS order
|
|
364
|
+
// - the per-line qty across all parcels for a given line_id
|
|
365
|
+
// sums to exactly the order_line.qty (no quantity created, no
|
|
366
|
+
// quantity dropped)
|
|
367
|
+
// - per-parcel qty is a positive integer
|
|
368
|
+
function _planManual(orderId, lines, manualPlan) {
|
|
369
|
+
if (!Array.isArray(manualPlan) || manualPlan.length === 0) {
|
|
370
|
+
throw new TypeError("split-shipments.planSplit(manual): manualPlan must be a non-empty array");
|
|
371
|
+
}
|
|
372
|
+
if (manualPlan.length > MAX_PARCELS) {
|
|
373
|
+
throw new TypeError("split-shipments.planSplit(manual): manualPlan must contain ≤ " +
|
|
374
|
+
MAX_PARCELS + " parcels");
|
|
375
|
+
}
|
|
376
|
+
var lineQty = {};
|
|
377
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
378
|
+
lineQty[lines[i].id] = { available: lines[i].qty, consumed: 0 };
|
|
379
|
+
}
|
|
380
|
+
var parcels = [];
|
|
381
|
+
for (var p = 0; p < manualPlan.length; p += 1) {
|
|
382
|
+
var parcel = manualPlan[p];
|
|
383
|
+
if (!parcel || typeof parcel !== "object") {
|
|
384
|
+
throw new TypeError("split-shipments.planSplit(manual): manualPlan[" + p + "] must be an object");
|
|
385
|
+
}
|
|
386
|
+
if (!Array.isArray(parcel.lines) || parcel.lines.length === 0) {
|
|
387
|
+
throw new TypeError("split-shipments.planSplit(manual): manualPlan[" + p + "].lines must be a non-empty array");
|
|
388
|
+
}
|
|
389
|
+
if (parcel.lines.length > MAX_LINES_PER_PARCEL) {
|
|
390
|
+
throw new TypeError("split-shipments.planSplit(manual): manualPlan[" + p + "].lines must contain ≤ " +
|
|
391
|
+
MAX_LINES_PER_PARCEL + " entries");
|
|
392
|
+
}
|
|
393
|
+
var rationale = _shortText(parcel.rationale, "manualPlan[" + p + "].rationale", MAX_RATIONALE_LEN) || "manual";
|
|
394
|
+
var planLines = [];
|
|
395
|
+
for (var k = 0; k < parcel.lines.length; k += 1) {
|
|
396
|
+
var pl = parcel.lines[k];
|
|
397
|
+
if (!pl || typeof pl !== "object") {
|
|
398
|
+
throw new TypeError("split-shipments.planSplit(manual): manualPlan[" + p + "].lines[" + k + "] must be an object");
|
|
399
|
+
}
|
|
400
|
+
_uuid(pl.line_id, "manualPlan[" + p + "].lines[" + k + "].line_id");
|
|
401
|
+
_positiveInt(pl.qty, "manualPlan[" + p + "].lines[" + k + "].qty");
|
|
402
|
+
if (!lineQty[pl.line_id]) {
|
|
403
|
+
throw new TypeError("split-shipments.planSplit(manual): line_id " + pl.line_id +
|
|
404
|
+
" does not belong to order " + orderId);
|
|
405
|
+
}
|
|
406
|
+
lineQty[pl.line_id].consumed += pl.qty;
|
|
407
|
+
planLines.push({ line_id: pl.line_id, qty: pl.qty });
|
|
408
|
+
}
|
|
409
|
+
parcels.push({ rationale: rationale, lines: planLines });
|
|
410
|
+
}
|
|
411
|
+
// Conservation check — every order_line.qty must be fully
|
|
412
|
+
// consumed by the manual plan. Refuse with a precise diff so the
|
|
413
|
+
// operator can spot the missing/extra unit at a glance.
|
|
414
|
+
var ids = Object.keys(lineQty);
|
|
415
|
+
for (var x = 0; x < ids.length; x += 1) {
|
|
416
|
+
var cell = lineQty[ids[x]];
|
|
417
|
+
if (cell.consumed !== cell.available) {
|
|
418
|
+
throw new TypeError("split-shipments.planSplit(manual): line_id " + ids[x] +
|
|
419
|
+
" has order_line qty=" + cell.available + " but manualPlan consumes " + cell.consumed);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return parcels;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---- recommendStrategy heuristic -------------------------------------
|
|
426
|
+
//
|
|
427
|
+
// Operator hint: given the order's shape + which composition deps
|
|
428
|
+
// are wired, return the strategy most likely to produce a useful
|
|
429
|
+
// split.
|
|
430
|
+
// 1. If `backorder` is wired AND the order has a mix of pending
|
|
431
|
+
// backorder lines + non-backorder lines → 'availability'
|
|
432
|
+
// 2. Else if `vendors` is wired AND the order's SKUs map to >1
|
|
433
|
+
// distinct vendor → 'vendor'
|
|
434
|
+
// 3. Else if `inventoryLocations` is wired AND routeOrder would
|
|
435
|
+
// span >1 location → 'location'
|
|
436
|
+
// 4. Else 'manual' — no automatic split signal.
|
|
437
|
+
async function _recommendStrategy(orderId, lines) {
|
|
438
|
+
if (backorder) {
|
|
439
|
+
var pendingCount = 0;
|
|
440
|
+
var nonPendingCount = 0;
|
|
441
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
442
|
+
var pending = await backorder.pendingForSku(lines[i].sku);
|
|
443
|
+
var match = false;
|
|
444
|
+
for (var j = 0; j < pending.length; j += 1) {
|
|
445
|
+
if (pending[j].order_id === orderId) { match = true; break; }
|
|
446
|
+
}
|
|
447
|
+
if (match) pendingCount += 1; else nonPendingCount += 1;
|
|
448
|
+
}
|
|
449
|
+
if (pendingCount > 0 && nonPendingCount > 0) {
|
|
450
|
+
return "availability";
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (vendors) {
|
|
454
|
+
var distinct = {};
|
|
455
|
+
for (var v = 0; v < lines.length; v += 1) {
|
|
456
|
+
var ven = await vendors.vendorForSku(lines[v].sku);
|
|
457
|
+
if (ven && ven.slug) distinct[ven.slug] = true;
|
|
458
|
+
}
|
|
459
|
+
if (Object.keys(distinct).length > 1) {
|
|
460
|
+
return "vendor";
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
if (inventoryLocations) {
|
|
464
|
+
try {
|
|
465
|
+
var routed = await inventoryLocations.routeOrder({
|
|
466
|
+
lines: lines.map(function (l) { return { sku: l.sku, quantity: l.qty }; }),
|
|
467
|
+
});
|
|
468
|
+
if (routed.allocation.length > 1) {
|
|
469
|
+
return "location";
|
|
470
|
+
}
|
|
471
|
+
} catch (_e) {
|
|
472
|
+
// routeOrder may refuse (no active locations etc) — fall
|
|
473
|
+
// through to manual rather than surface a routing-layer
|
|
474
|
+
// error from a read-only recommendation call. Drop-silent
|
|
475
|
+
// by design — the recommendation is advisory, the operator
|
|
476
|
+
// can still pick a strategy explicitly.
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return "manual";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ---- DB helpers ------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
async function _getPlanRow(planId) {
|
|
485
|
+
var r = await query("SELECT * FROM split_shipment_plans WHERE id = ?1", [planId]);
|
|
486
|
+
return r.rows[0] || null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function _hydratePlan(row) {
|
|
490
|
+
if (!row) return null;
|
|
491
|
+
var planJson;
|
|
492
|
+
try { planJson = JSON.parse(row.plan_json); }
|
|
493
|
+
catch (_e) { planJson = []; }
|
|
494
|
+
var shipmentIds;
|
|
495
|
+
if (row.executed_shipment_ids_json) {
|
|
496
|
+
try { shipmentIds = JSON.parse(row.executed_shipment_ids_json); }
|
|
497
|
+
catch (_e2) { shipmentIds = []; }
|
|
498
|
+
} else {
|
|
499
|
+
shipmentIds = [];
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
id: row.id,
|
|
503
|
+
order_id: row.order_id,
|
|
504
|
+
strategy: row.strategy,
|
|
505
|
+
shipments: planJson,
|
|
506
|
+
shipment_ids: shipmentIds,
|
|
507
|
+
status: row.status,
|
|
508
|
+
proposed_at: row.proposed_at,
|
|
509
|
+
executed_at: row.executed_at,
|
|
510
|
+
cancelled_at: row.cancelled_at,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
STRATEGIES: STRATEGIES,
|
|
516
|
+
STATUSES: STATUSES,
|
|
517
|
+
|
|
518
|
+
// Walk the order, derive a proposed plan under the named
|
|
519
|
+
// strategy, and persist a `proposed` row. The plan is returned
|
|
520
|
+
// in the hydrated `{ id, order_id, strategy, shipments: [...] }`
|
|
521
|
+
// shape so the caller can review without a second read.
|
|
522
|
+
planSplit: async function (input) {
|
|
523
|
+
if (!input || typeof input !== "object") {
|
|
524
|
+
throw new TypeError("split-shipments.planSplit: input object required");
|
|
525
|
+
}
|
|
526
|
+
_uuid(input.order_id, "order_id");
|
|
527
|
+
var strategy = input.strategy == null ? "availability" : input.strategy;
|
|
528
|
+
_strategy(strategy);
|
|
529
|
+
|
|
530
|
+
var lines = await _orderLines(input.order_id);
|
|
531
|
+
if (!lines) {
|
|
532
|
+
throw new TypeError("split-shipments.planSplit: order " + input.order_id + " not found");
|
|
533
|
+
}
|
|
534
|
+
if (!lines.length) {
|
|
535
|
+
throw new TypeError("split-shipments.planSplit: order " + input.order_id + " has no order_lines");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
var parcels;
|
|
539
|
+
if (strategy === "availability") {
|
|
540
|
+
parcels = await _planAvailability(input.order_id, lines);
|
|
541
|
+
} else if (strategy === "location") {
|
|
542
|
+
parcels = await _planLocation(input.order_id, lines, input.strategyOpts);
|
|
543
|
+
} else if (strategy === "vendor") {
|
|
544
|
+
parcels = await _planVendor(input.order_id, lines);
|
|
545
|
+
} else {
|
|
546
|
+
parcels = _planManual(input.order_id, lines, input.manualPlan);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (!parcels.length) {
|
|
550
|
+
throw new TypeError("split-shipments.planSplit: strategy " + JSON.stringify(strategy) +
|
|
551
|
+
" produced no parcels for order " + input.order_id);
|
|
552
|
+
}
|
|
553
|
+
if (parcels.length > MAX_PARCELS) {
|
|
554
|
+
throw new TypeError("split-shipments.planSplit: strategy " + JSON.stringify(strategy) +
|
|
555
|
+
" produced " + parcels.length + " parcels — refusing > " + MAX_PARCELS);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
var id = _b().uuid.v7();
|
|
559
|
+
var ts = _now();
|
|
560
|
+
await query(
|
|
561
|
+
"INSERT INTO split_shipment_plans (id, order_id, strategy, plan_json, " +
|
|
562
|
+
"executed_shipment_ids_json, status, proposed_at, executed_at, cancelled_at) " +
|
|
563
|
+
"VALUES (?1, ?2, ?3, ?4, NULL, 'proposed', ?5, NULL, NULL)",
|
|
564
|
+
[id, input.order_id, strategy, JSON.stringify(parcels), ts],
|
|
565
|
+
);
|
|
566
|
+
return _hydratePlan(await _getPlanRow(id));
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
// Walk an executed-once plan and write one `shipments` row per
|
|
570
|
+
// parcel via the injected orderTracking primitive. Refuses if
|
|
571
|
+
// the plan is not in `proposed` status — re-executing a plan
|
|
572
|
+
// would silently double-create shipments.
|
|
573
|
+
executeSplit: async function (input) {
|
|
574
|
+
if (!input || typeof input !== "object") {
|
|
575
|
+
throw new TypeError("split-shipments.executeSplit: input object required");
|
|
576
|
+
}
|
|
577
|
+
_uuid(input.order_id, "order_id");
|
|
578
|
+
if (!input.plan || typeof input.plan !== "object") {
|
|
579
|
+
throw new TypeError("split-shipments.executeSplit: plan object required");
|
|
580
|
+
}
|
|
581
|
+
_uuid(input.plan.id, "plan.id");
|
|
582
|
+
var row = await _getPlanRow(input.plan.id);
|
|
583
|
+
if (!row) {
|
|
584
|
+
throw new TypeError("split-shipments.executeSplit: plan " + input.plan.id + " not found");
|
|
585
|
+
}
|
|
586
|
+
if (row.order_id !== input.order_id) {
|
|
587
|
+
throw new TypeError("split-shipments.executeSplit: plan " + input.plan.id +
|
|
588
|
+
" belongs to a different order");
|
|
589
|
+
}
|
|
590
|
+
if (row.status !== "proposed") {
|
|
591
|
+
throw new TypeError("split-shipments.executeSplit: plan " + input.plan.id +
|
|
592
|
+
" is " + row.status + ", only proposed plans can be executed");
|
|
593
|
+
}
|
|
594
|
+
var carrier = input.carrier || "other";
|
|
595
|
+
var carrierOtherName = input.carrier_other_name || null;
|
|
596
|
+
if (carrier === "other" && !carrierOtherName) {
|
|
597
|
+
// The orderTracking primitive enforces this at its own
|
|
598
|
+
// surface — surface a clearer message up-front so the
|
|
599
|
+
// operator doesn't have to decode the per-parcel error.
|
|
600
|
+
carrierOtherName = "split-shipment-pending";
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
var hydrated = _hydratePlan(row);
|
|
604
|
+
var shipmentIds = [];
|
|
605
|
+
for (var i = 0; i < hydrated.shipments.length; i += 1) {
|
|
606
|
+
var parcel = hydrated.shipments[i];
|
|
607
|
+
var notes = "split:" + parcel.rationale + " (" + (i + 1) + "/" + hydrated.shipments.length + ")";
|
|
608
|
+
var createInput = {
|
|
609
|
+
order_id: input.order_id,
|
|
610
|
+
carrier: carrier,
|
|
611
|
+
notes: notes,
|
|
612
|
+
};
|
|
613
|
+
if (carrier === "other") {
|
|
614
|
+
createInput.carrier_other_name = carrierOtherName;
|
|
615
|
+
}
|
|
616
|
+
var s = await orderTracking.createShipment(createInput);
|
|
617
|
+
shipmentIds.push(s.id);
|
|
618
|
+
}
|
|
619
|
+
var ts = _now();
|
|
620
|
+
await query(
|
|
621
|
+
"UPDATE split_shipment_plans SET status = 'executed', executed_shipment_ids_json = ?1, " +
|
|
622
|
+
"executed_at = ?2 WHERE id = ?3",
|
|
623
|
+
[JSON.stringify(shipmentIds), ts, input.plan.id],
|
|
624
|
+
);
|
|
625
|
+
return _hydratePlan(await _getPlanRow(input.plan.id));
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
// Operator override: combine N executed parcels into one. Returns
|
|
629
|
+
// the updated target shipment. The source shipment rows are NOT
|
|
630
|
+
// deleted — the audit trail (created_at + carrier + notes)
|
|
631
|
+
// survives — instead each source row's notes is appended with a
|
|
632
|
+
// `merged-into:<target_id>` marker and its status is left
|
|
633
|
+
// intact. The plan row's shipment-id list is rewritten so
|
|
634
|
+
// `splitsForOrder(...)` reflects the new shape.
|
|
635
|
+
mergeShipments: async function (input) {
|
|
636
|
+
if (!input || typeof input !== "object") {
|
|
637
|
+
throw new TypeError("split-shipments.mergeShipments: input object required");
|
|
638
|
+
}
|
|
639
|
+
if (!Array.isArray(input.source_shipment_ids) || input.source_shipment_ids.length === 0) {
|
|
640
|
+
throw new TypeError("split-shipments.mergeShipments: source_shipment_ids must be a non-empty array");
|
|
641
|
+
}
|
|
642
|
+
_uuid(input.target_shipment_id, "target_shipment_id");
|
|
643
|
+
for (var i = 0; i < input.source_shipment_ids.length; i += 1) {
|
|
644
|
+
_uuid(input.source_shipment_ids[i], "source_shipment_ids[" + i + "]");
|
|
645
|
+
if (input.source_shipment_ids[i] === input.target_shipment_id) {
|
|
646
|
+
throw new TypeError("split-shipments.mergeShipments: source_shipment_ids must not contain target_shipment_id");
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
var targetRow = (await query("SELECT * FROM shipments WHERE id = ?1", [input.target_shipment_id])).rows[0];
|
|
651
|
+
if (!targetRow) {
|
|
652
|
+
throw new TypeError("split-shipments.mergeShipments: target shipment " +
|
|
653
|
+
input.target_shipment_id + " not found");
|
|
654
|
+
}
|
|
655
|
+
var sourceRows = [];
|
|
656
|
+
for (var j = 0; j < input.source_shipment_ids.length; j += 1) {
|
|
657
|
+
var sid = input.source_shipment_ids[j];
|
|
658
|
+
var srcRow = (await query("SELECT * FROM shipments WHERE id = ?1", [sid])).rows[0];
|
|
659
|
+
if (!srcRow) {
|
|
660
|
+
throw new TypeError("split-shipments.mergeShipments: source shipment " + sid + " not found");
|
|
661
|
+
}
|
|
662
|
+
if (srcRow.order_id !== targetRow.order_id) {
|
|
663
|
+
throw new TypeError("split-shipments.mergeShipments: source shipment " + sid +
|
|
664
|
+
" belongs to a different order than the target");
|
|
665
|
+
}
|
|
666
|
+
sourceRows.push(srcRow);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Stamp each source row's notes with the merged-into marker so
|
|
670
|
+
// the operator-facing shipment list shows where the parcel
|
|
671
|
+
// went. Cap the appended string so a many-merge sequence
|
|
672
|
+
// doesn't grow the notes column without bound.
|
|
673
|
+
var ts = _now();
|
|
674
|
+
for (var k = 0; k < sourceRows.length; k += 1) {
|
|
675
|
+
var sr = sourceRows[k];
|
|
676
|
+
var marker = " | merged-into:" + input.target_shipment_id;
|
|
677
|
+
var nextNotes = (sr.notes || "") + marker;
|
|
678
|
+
if (nextNotes.length > 2048) nextNotes = nextNotes.slice(0, 2048);
|
|
679
|
+
await query(
|
|
680
|
+
"UPDATE shipments SET notes = ?1, updated_at = ?2 WHERE id = ?3",
|
|
681
|
+
[nextNotes, ts, sr.id],
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Rewrite the plan row's executed_shipment_ids_json so
|
|
686
|
+
// splitsForOrder reflects the new shape. Only the
|
|
687
|
+
// `executed` plan whose shipment list contains the target is
|
|
688
|
+
// rewritten — older `cancelled` rows stay untouched.
|
|
689
|
+
var plans = (await query(
|
|
690
|
+
"SELECT * FROM split_shipment_plans WHERE order_id = ?1 AND status = 'executed'",
|
|
691
|
+
[targetRow.order_id],
|
|
692
|
+
)).rows;
|
|
693
|
+
for (var m = 0; m < plans.length; m += 1) {
|
|
694
|
+
var planRow = plans[m];
|
|
695
|
+
var ids;
|
|
696
|
+
try { ids = JSON.parse(planRow.executed_shipment_ids_json || "[]"); }
|
|
697
|
+
catch (_e) { ids = []; }
|
|
698
|
+
if (ids.indexOf(input.target_shipment_id) === -1) continue;
|
|
699
|
+
var keepIds = [];
|
|
700
|
+
for (var n = 0; n < ids.length; n += 1) {
|
|
701
|
+
if (input.source_shipment_ids.indexOf(ids[n]) === -1) keepIds.push(ids[n]);
|
|
702
|
+
}
|
|
703
|
+
await query(
|
|
704
|
+
"UPDATE split_shipment_plans SET executed_shipment_ids_json = ?1 WHERE id = ?2",
|
|
705
|
+
[JSON.stringify(keepIds), planRow.id],
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return {
|
|
710
|
+
target_shipment_id: input.target_shipment_id,
|
|
711
|
+
merged_source_ids: input.source_shipment_ids.slice(),
|
|
712
|
+
merged_at: ts,
|
|
713
|
+
};
|
|
714
|
+
},
|
|
715
|
+
|
|
716
|
+
// Read every plan (proposed / executed / cancelled) for an
|
|
717
|
+
// order, newest first. The v7-uuid PK sorts lexicographically
|
|
718
|
+
// by creation order so `ORDER BY id DESC` is equivalent to
|
|
719
|
+
// `ORDER BY proposed_at DESC` without needing a second index.
|
|
720
|
+
splitsForOrder: async function (orderId) {
|
|
721
|
+
_uuid(orderId, "order_id");
|
|
722
|
+
var r = await query(
|
|
723
|
+
"SELECT * FROM split_shipment_plans WHERE order_id = ?1 ORDER BY id DESC",
|
|
724
|
+
[orderId],
|
|
725
|
+
);
|
|
726
|
+
return r.rows.map(_hydratePlan);
|
|
727
|
+
},
|
|
728
|
+
|
|
729
|
+
// Cancel a proposed plan before it executes. Refuses on
|
|
730
|
+
// already-executed (the shipment rows exist; cancellation
|
|
731
|
+
// requires the operator to handle them via the tracking
|
|
732
|
+
// primitive's per-shipment cancellation flow) or already-
|
|
733
|
+
// cancelled (no-op refused so a double-call surfaces).
|
|
734
|
+
cancelPlan: async function (planId) {
|
|
735
|
+
_uuid(planId, "plan_id");
|
|
736
|
+
var row = await _getPlanRow(planId);
|
|
737
|
+
if (!row) {
|
|
738
|
+
throw new TypeError("split-shipments.cancelPlan: plan " + planId + " not found");
|
|
739
|
+
}
|
|
740
|
+
if (row.status !== "proposed") {
|
|
741
|
+
throw new TypeError("split-shipments.cancelPlan: plan " + planId +
|
|
742
|
+
" is " + row.status + ", only proposed plans can be cancelled");
|
|
743
|
+
}
|
|
744
|
+
var ts = _now();
|
|
745
|
+
await query(
|
|
746
|
+
"UPDATE split_shipment_plans SET status = 'cancelled', cancelled_at = ?1 WHERE id = ?2",
|
|
747
|
+
[ts, planId],
|
|
748
|
+
);
|
|
749
|
+
return _hydratePlan(await _getPlanRow(planId));
|
|
750
|
+
},
|
|
751
|
+
|
|
752
|
+
// Heuristic — given the order's shape + which composition deps
|
|
753
|
+
// are wired, return the strategy most likely to produce a useful
|
|
754
|
+
// split. Pure read; never writes a plan row.
|
|
755
|
+
recommendStrategy: async function (orderId) {
|
|
756
|
+
_uuid(orderId, "order_id");
|
|
757
|
+
var lines = await _orderLines(orderId);
|
|
758
|
+
if (!lines) {
|
|
759
|
+
throw new TypeError("split-shipments.recommendStrategy: order " + orderId + " not found");
|
|
760
|
+
}
|
|
761
|
+
if (!lines.length) {
|
|
762
|
+
throw new TypeError("split-shipments.recommendStrategy: order " + orderId + " has no order_lines");
|
|
763
|
+
}
|
|
764
|
+
return await _recommendStrategy(orderId, lines);
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
module.exports = {
|
|
770
|
+
create: create,
|
|
771
|
+
STRATEGIES: STRATEGIES,
|
|
772
|
+
STATUSES: STATUSES,
|
|
773
|
+
};
|