@blamejs/blamejs-shop 0.0.64 → 0.0.66

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