@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/business-hours.js +980 -0
  5. package/lib/click-and-collect.js +711 -0
  6. package/lib/clickstream.js +713 -0
  7. package/lib/cost-layers.js +774 -0
  8. package/lib/credit-limits.js +752 -0
  9. package/lib/currency-rounding.js +525 -0
  10. package/lib/customer-activity.js +862 -0
  11. package/lib/customer-notes.js +712 -0
  12. package/lib/customer-risk-profile.js +593 -0
  13. package/lib/customer-surveys.js +1012 -0
  14. package/lib/damage-photos.js +473 -0
  15. package/lib/discount-allocation.js +557 -0
  16. package/lib/dropship-forwarding.js +645 -0
  17. package/lib/email-templates.js +817 -0
  18. package/lib/index.js +45 -0
  19. package/lib/inventory-allocations.js +559 -0
  20. package/lib/inventory-writeoffs.js +636 -0
  21. package/lib/knowledge-base.js +1104 -0
  22. package/lib/locale-router.js +1077 -0
  23. package/lib/operator-roles.js +768 -0
  24. package/lib/order-escalation.js +951 -0
  25. package/lib/order-ratings.js +495 -0
  26. package/lib/order-tags.js +944 -0
  27. package/lib/packing-slips.js +810 -0
  28. package/lib/payment-retries.js +816 -0
  29. package/lib/pick-lists.js +639 -0
  30. package/lib/pixel-events.js +995 -0
  31. package/lib/preorder.js +595 -0
  32. package/lib/print-queue.js +681 -0
  33. package/lib/product-qa.js +749 -0
  34. package/lib/promo-bundles.js +835 -0
  35. package/lib/push-notifications.js +937 -0
  36. package/lib/refund-automation.js +853 -0
  37. package/lib/reorder-reminders.js +798 -0
  38. package/lib/robots-config.js +753 -0
  39. package/lib/seller-signup.js +1052 -0
  40. package/lib/site-redirects.js +690 -0
  41. package/lib/sitemap-generator.js +717 -0
  42. package/lib/subscription-gifts.js +710 -0
  43. package/lib/tax-cert-renewals.js +632 -0
  44. package/lib/theme-assets.js +711 -0
  45. package/lib/tier-benefits.js +776 -0
  46. package/lib/vendor/MANIFEST.json +2 -2
  47. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  48. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  49. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  52. package/lib/wishlist-alerts.js +842 -0
  53. package/lib/wishlist-sharing.js +718 -0
  54. package/package.json +1 -1
@@ -0,0 +1,639 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.pickLists
4
+ * @title Pick lists — warehouse fulfillment worksheet that consolidates
5
+ * N open orders into an aisle-sequenced picker route
6
+ *
7
+ * @intro
8
+ * Thirty orders just rolled in. Each one is a mix of two or three
9
+ * SKUs scattered across different aisles. A naive "pick each order
10
+ * in turn" walk has the picker criss-crossing the warehouse for
11
+ * half an hour. The fix is to consolidate the open orders into a
12
+ * single sequenced worksheet keyed by aisle position — the picker
13
+ * walks each aisle once, scans every SKU on the route, and the
14
+ * system stitches the picked qty back onto the parent orders.
15
+ *
16
+ * Lifecycle (four-state FSM):
17
+ *
18
+ * generateList({ location_code, order_ids?, max_lines?, sort_by? })
19
+ * Snapshots a set of open orders into a worksheet. `order_ids`
20
+ * is optional — when omitted, the primitive selects every
21
+ * `paid` / `fulfilling` order in `created_at` order up to
22
+ * `max_lines`. When supplied, the primitive validates each id
23
+ * and refuses unknown / terminal-state orders (cancelled /
24
+ * refunded). For every selected order, every order_line
25
+ * produces one pick_list_line row with the parent qty as
26
+ * `expected_quantity`. The `aisle_position` column is populated
27
+ * from the optional `inventoryLocations.binForSku(sku,
28
+ * location_code)` verb when wired, falling back to the sku
29
+ * itself (deterministic + sortable, no fabricated bin data).
30
+ * The row ordering at insert time matches `sort_by`:
31
+ * aisle — walk-order across the warehouse (default)
32
+ * sku — alphabetic SKU grouping (kit-pack workflows)
33
+ * priority — every line from order A, then every line from
34
+ * order B (operator-supplied priority via order_id
35
+ * array order)
36
+ * order_id — same as priority but with the lexicographic
37
+ * order_id ordering when order_ids isn't supplied
38
+ * `max_lines` caps the total line count — once the cap is hit
39
+ * the remaining selected orders are skipped (the worksheet is
40
+ * a unit of picker work, not a queue; the operator generates
41
+ * another list for the next batch).
42
+ *
43
+ * confirmLine({ list_id, line_id, picker_id, actual_quantity? })
44
+ * Stamps `actual_quantity` (defaults to `expected_quantity`
45
+ * when the line was picked clean), `picked_by`, `picked_at`
46
+ * on the line. Transitions the list generated -> in_progress
47
+ * on the first confirm. Idempotent on the per-line value —
48
+ * calling confirmLine twice overwrites the prior values (a
49
+ * recount). Refuses confirms once the list is complete or
50
+ * cancelled (the picker is supposed to call markListComplete
51
+ * once every line is settled; double-confirming after a
52
+ * shipment has already been created would leave an audit hole).
53
+ *
54
+ * markListComplete({ list_id })
55
+ * in_progress -> complete. Refuses if any line still has
56
+ * actual_quantity = NULL (the picker hasn't settled every row
57
+ * — short-picks are settled by confirming with
58
+ * actual_quantity=0, not by leaving the column null). For
59
+ * every parent order represented on the list, calls
60
+ * `orderTracking.createShipment({ order_id, carrier:
61
+ * "pickup", notes: "pick-list:<id>" })` once. The shipment id
62
+ * is captured on the return value so the operator UI can
63
+ * deep-link to each generated shipment. Stamps `completed_at`.
64
+ *
65
+ * cancelList({ list_id, reason })
66
+ * generated|in_progress -> cancelled. Persists the reason +
67
+ * cancelled_at. The list's lines are retained (the FK CASCADE
68
+ * is not invoked — operators still need the audit trail of
69
+ * partial picks against the original worksheet). Cancelling a
70
+ * complete list is refused — the shipments have already been
71
+ * created and the variance reconciliation has landed on the
72
+ * order ledger.
73
+ *
74
+ * Reads:
75
+ * getList(list_id) — hydrated header + lines
76
+ * listLists({ location_code?, status? }) — filtered headers
77
+ * discrepanciesFor(list_id) — per-line short / over
78
+ * picks (actual !=
79
+ * expected) for the
80
+ * operator's variance
81
+ * report
82
+ *
83
+ * Composition:
84
+ * - b.uuid.v7 — list / line PKs (sortable)
85
+ * - b.guardUuid — strict UUID validation on every id
86
+ * - order — SOLE owner of the orders table read;
87
+ * generateList composes `order.get(id)`
88
+ * to fetch order_lines. Required.
89
+ * - orderTracking — SOLE owner of shipment creation;
90
+ * markListComplete composes
91
+ * `orderTracking.createShipment(...)`
92
+ * per parent order. Required.
93
+ * - inventoryLocations — optional. When wired, the primitive
94
+ * probes for `binForSku(sku,
95
+ * location_code)` to populate the
96
+ * aisle_position column. Falls back
97
+ * to the sku itself when the verb is
98
+ * absent so the dependency is
99
+ * truly optional.
100
+ *
101
+ * Three-tier input validation (use the discipline; don't write the
102
+ * labels): every public verb here is either a config-time entry
103
+ * point (factory create) or a defensive request-shape reader. All
104
+ * throw on bad input — no drop-silent hot-path sinks.
105
+ *
106
+ * @primitive pickLists
107
+ * @related order, orderTracking, inventoryLocations, splitShipments
108
+ */
109
+
110
+ var bShop;
111
+ function _b() {
112
+ if (!bShop) bShop = require("./index");
113
+ return bShop.framework;
114
+ }
115
+
116
+ // ---- constants ----------------------------------------------------------
117
+
118
+ var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
119
+ var PICKER_RE = /^[\S\s]{1,128}$/;
120
+ var MAX_REASON = 280;
121
+ var MAX_ORDER_IDS = 500;
122
+ var DEFAULT_MAX_LINES = 250;
123
+ var MAX_LINES_CAP = 2000;
124
+
125
+ var LIST_STATUSES = Object.freeze(["generated", "in_progress", "complete", "cancelled"]);
126
+ var SORT_BY_ENUM = Object.freeze(["aisle", "sku", "priority", "order_id"]);
127
+
128
+ // Orders eligible to be folded into a pick list. Pending orders
129
+ // haven't reached payment yet; shipped/delivered are past the
130
+ // picker's hands; refunded/cancelled are terminal-sad. Paid +
131
+ // fulfilling are the two states where the warehouse owes the
132
+ // customer a shipment.
133
+ var ELIGIBLE_ORDER_STATES = Object.freeze(["paid", "fulfilling"]);
134
+
135
+ // ---- validators ---------------------------------------------------------
136
+
137
+ function _id(s, label) {
138
+ try {
139
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
140
+ } catch (e) {
141
+ throw new TypeError("pick-lists: " + label + " — " + (e && e.message || "invalid UUID"));
142
+ }
143
+ }
144
+ function _code(s, label) {
145
+ if (typeof s !== "string" || !CODE_RE.test(s)) {
146
+ throw new TypeError("pick-lists: " + (label || "location_code") +
147
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
148
+ }
149
+ }
150
+ function _sortBy(s) {
151
+ if (SORT_BY_ENUM.indexOf(s) === -1) {
152
+ throw new TypeError("pick-lists: sort_by must be one of " + SORT_BY_ENUM.join(", ") +
153
+ ", got " + JSON.stringify(s));
154
+ }
155
+ }
156
+ function _status(s) {
157
+ if (LIST_STATUSES.indexOf(s) === -1) {
158
+ throw new TypeError("pick-lists: status must be one of " + LIST_STATUSES.join(", ") +
159
+ ", got " + JSON.stringify(s));
160
+ }
161
+ }
162
+ function _nonNegInt(n, label) {
163
+ if (!Number.isInteger(n) || n < 0) {
164
+ throw new TypeError("pick-lists: " + label + " must be a non-negative integer");
165
+ }
166
+ }
167
+ function _picker(s) {
168
+ if (typeof s !== "string" || !PICKER_RE.test(s) || s.length > 128) {
169
+ throw new TypeError("pick-lists: picker_id must be a non-empty string ≤ 128 chars");
170
+ }
171
+ }
172
+ function _reason(s) {
173
+ if (s == null) return "";
174
+ if (typeof s !== "string" || s.length > MAX_REASON) {
175
+ throw new TypeError("pick-lists: reason must be a string ≤ " + MAX_REASON + " chars");
176
+ }
177
+ return s;
178
+ }
179
+ function _maxLines(n) {
180
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LINES_CAP) {
181
+ throw new TypeError("pick-lists: max_lines must be an integer in 1..." + MAX_LINES_CAP);
182
+ }
183
+ }
184
+
185
+ // Monotonic clock — every write that stamps a timestamp uses _now()
186
+ // instead of Date.now() directly so a generateList + immediate
187
+ // confirmLine + markListComplete sequence never collides on the
188
+ // millisecond boundary. Operators reading the timeline get a strict
189
+ // ordering even on hosts where Date.now() is coarse.
190
+ var _lastTs = 0;
191
+ function _now() {
192
+ var t = Date.now();
193
+ if (t <= _lastTs) t = _lastTs + 1;
194
+ _lastTs = t;
195
+ return t;
196
+ }
197
+
198
+ // ---- factory ------------------------------------------------------------
199
+
200
+ function create(opts) {
201
+ opts = opts || {};
202
+ if (!opts.order || typeof opts.order.get !== "function") {
203
+ throw new TypeError("pick-lists.create: opts.order with get(id) is required");
204
+ }
205
+ if (!opts.orderTracking || typeof opts.orderTracking.createShipment !== "function") {
206
+ throw new TypeError("pick-lists.create: opts.orderTracking with createShipment() is required");
207
+ }
208
+ var orderPrim = opts.order;
209
+ var orderTracking = opts.orderTracking;
210
+ var locations = opts.inventoryLocations || null;
211
+ var query = opts.query;
212
+ if (!query) {
213
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
214
+ }
215
+
216
+ // Resolve the aisle_position for a (sku, location_code) tuple. When
217
+ // the inventoryLocations dep is wired AND exposes a binForSku verb,
218
+ // delegate to it (operators can ship their own bin map). Otherwise
219
+ // fall back to the sku itself — deterministic, sortable, and
220
+ // honest: the worksheet groups identical SKUs together even when
221
+ // the warehouse has no explicit bin mapping.
222
+ async function _aislePositionFor(sku, locationCode) {
223
+ if (locations && typeof locations.binForSku === "function") {
224
+ var bin = await locations.binForSku(sku, locationCode);
225
+ if (typeof bin === "string" && bin.length) return bin;
226
+ }
227
+ return sku;
228
+ }
229
+
230
+ // Hydrate a list row + its lines into a single object. Lines come
231
+ // back in `sequence_number ASC` order — the column was populated at
232
+ // generateList time per the operator's chosen sort_by, so the
233
+ // picker UI walks the route in the same order regardless of how
234
+ // confirmLine updates interleave on the line rows.
235
+ async function _getHydrated(id) {
236
+ var rRow = await query("SELECT * FROM pick_lists WHERE id = ?1", [id]);
237
+ if (!rRow.rows.length) return null;
238
+ var list = rRow.rows[0];
239
+ var rLines = await query(
240
+ "SELECT * FROM pick_list_lines WHERE list_id = ?1 " +
241
+ "ORDER BY sequence_number ASC, id ASC",
242
+ [id],
243
+ );
244
+ list.lines = rLines.rows;
245
+ return list;
246
+ }
247
+
248
+ // Sort the (about-to-be-inserted) line objects by the operator's
249
+ // chosen sort_by. The DB index on (aisle_position) means the read
250
+ // path can re-sort; sorting at insert lets the operator see the
251
+ // intended order via raw row inspection too.
252
+ function _applySort(lines, sortBy, orderIds) {
253
+ if (sortBy === "aisle") {
254
+ lines.sort(function (a, b) {
255
+ if (a.aisle_position < b.aisle_position) return -1;
256
+ if (a.aisle_position > b.aisle_position) return 1;
257
+ if (a.sku < b.sku) return -1;
258
+ if (a.sku > b.sku) return 1;
259
+ return 0;
260
+ });
261
+ } else if (sortBy === "sku") {
262
+ lines.sort(function (a, b) {
263
+ if (a.sku < b.sku) return -1;
264
+ if (a.sku > b.sku) return 1;
265
+ if (a.order_id < b.order_id) return -1;
266
+ if (a.order_id > b.order_id) return 1;
267
+ return 0;
268
+ });
269
+ } else if (sortBy === "priority") {
270
+ // Operator-supplied order_ids carry the priority order. Build
271
+ // an index map so the sort is O(n log n) instead of O(n²).
272
+ var prio = Object.create(null);
273
+ for (var i = 0; i < orderIds.length; i += 1) prio[orderIds[i]] = i;
274
+ lines.sort(function (a, b) {
275
+ var pa = prio[a.order_id]; var pb = prio[b.order_id];
276
+ if (pa !== pb) return pa - pb;
277
+ if (a.sku < b.sku) return -1;
278
+ if (a.sku > b.sku) return 1;
279
+ return 0;
280
+ });
281
+ } else {
282
+ // order_id — lexicographic on the order_id column, then sku.
283
+ lines.sort(function (a, b) {
284
+ if (a.order_id < b.order_id) return -1;
285
+ if (a.order_id > b.order_id) return 1;
286
+ if (a.sku < b.sku) return -1;
287
+ if (a.sku > b.sku) return 1;
288
+ return 0;
289
+ });
290
+ }
291
+ }
292
+
293
+ return {
294
+ LIST_STATUSES: LIST_STATUSES,
295
+ SORT_BY_ENUM: SORT_BY_ENUM,
296
+ ELIGIBLE_ORDER_STATES: ELIGIBLE_ORDER_STATES,
297
+
298
+ // Generate a worksheet from N open orders for the given
299
+ // warehouse. Validates input, resolves order_ids (operator-
300
+ // supplied or selected from the eligible-state queue), composes
301
+ // `order.get(id)` to read each parent order's lines, populates
302
+ // `aisle_position` via inventoryLocations.binForSku when wired,
303
+ // sorts per `sort_by`, and persists header + lines + 'generated'
304
+ // status. Returns the hydrated list.
305
+ generateList: async function (input) {
306
+ if (!input || typeof input !== "object") {
307
+ throw new TypeError("pick-lists.generateList: input object required");
308
+ }
309
+ _code(input.location_code, "location_code");
310
+ var sortBy = input.sort_by == null ? "aisle" : input.sort_by;
311
+ _sortBy(sortBy);
312
+ var maxLines = input.max_lines == null ? DEFAULT_MAX_LINES : input.max_lines;
313
+ _maxLines(maxLines);
314
+
315
+ // Resolve order_ids. Operator-supplied: validate each id +
316
+ // refuse duplicates. Omitted: query the eligible-state queue
317
+ // ordered by created_at ASC (oldest-first — those orders have
318
+ // been waiting longest for fulfillment).
319
+ var orderIds;
320
+ if (input.order_ids != null) {
321
+ if (!Array.isArray(input.order_ids) || input.order_ids.length === 0) {
322
+ throw new TypeError("pick-lists.generateList: order_ids must be a non-empty array when supplied");
323
+ }
324
+ if (input.order_ids.length > MAX_ORDER_IDS) {
325
+ throw new TypeError("pick-lists.generateList: order_ids must contain ≤ " + MAX_ORDER_IDS + " entries");
326
+ }
327
+ var seen = Object.create(null);
328
+ orderIds = [];
329
+ for (var i = 0; i < input.order_ids.length; i += 1) {
330
+ var oid = _id(input.order_ids[i], "order_ids[" + i + "]");
331
+ if (seen[oid]) {
332
+ throw new TypeError("pick-lists.generateList: duplicate order_id " + JSON.stringify(oid));
333
+ }
334
+ seen[oid] = true;
335
+ orderIds.push(oid);
336
+ }
337
+ } else {
338
+ // Auto-select from eligible orders. The IN-clause is built
339
+ // from a fixed enum (ELIGIBLE_ORDER_STATES) so no operator
340
+ // input reaches the SQL string. The LIMIT bound is the
341
+ // worksheet's max_lines cap interpreted as "at most this many
342
+ // orders" — a conservative over-estimate (each order has ≥ 1
343
+ // line) that the per-line cap further constrains below.
344
+ var rOrders = await query(
345
+ "SELECT id FROM orders WHERE status IN ('paid', 'fulfilling') " +
346
+ "ORDER BY created_at ASC, id ASC LIMIT ?1",
347
+ [maxLines],
348
+ );
349
+ orderIds = rOrders.rows.map(function (r) { return r.id; });
350
+ if (orderIds.length === 0) {
351
+ throw new TypeError("pick-lists.generateList: no eligible orders found " +
352
+ "(none in status paid|fulfilling)");
353
+ }
354
+ }
355
+
356
+ // Fan-out: pull each order's lines via the composed order.get
357
+ // verb. Refuses unknown ids + ids whose status isn't eligible
358
+ // — both surface as a clear TypeError instead of silently
359
+ // producing a worksheet that omits the missing rows.
360
+ var staged = [];
361
+ for (var k = 0; k < orderIds.length; k += 1) {
362
+ var ord = await orderPrim.get(orderIds[k]);
363
+ if (!ord) {
364
+ throw new TypeError("pick-lists.generateList: order " + orderIds[k] + " not found");
365
+ }
366
+ if (ELIGIBLE_ORDER_STATES.indexOf(ord.status) === -1) {
367
+ throw new TypeError("pick-lists.generateList: order " + orderIds[k] +
368
+ " is in status " + ord.status + " (must be one of " +
369
+ ELIGIBLE_ORDER_STATES.join(", ") + ")");
370
+ }
371
+ for (var m = 0; m < ord.lines.length; m += 1) {
372
+ if (staged.length >= maxLines) break;
373
+ var ol = ord.lines[m];
374
+ var aisle = await _aislePositionFor(ol.sku, input.location_code);
375
+ staged.push({
376
+ order_id: ord.id,
377
+ sku: ol.sku,
378
+ variant_id: ol.variant_id == null ? null : ol.variant_id,
379
+ expected_quantity: ol.qty,
380
+ aisle_position: aisle,
381
+ });
382
+ }
383
+ if (staged.length >= maxLines) break;
384
+ }
385
+
386
+ if (staged.length === 0) {
387
+ throw new TypeError("pick-lists.generateList: selected orders produced zero lines");
388
+ }
389
+
390
+ _applySort(staged, sortBy, orderIds);
391
+
392
+ var listId = _b().uuid.v7();
393
+ var ts = _now();
394
+ try {
395
+ await query(
396
+ "INSERT INTO pick_lists (id, location_code, status, sort_by, generated_at) " +
397
+ "VALUES (?1, ?2, 'generated', ?3, ?4)",
398
+ [listId, input.location_code, sortBy, ts],
399
+ );
400
+ for (var n = 0; n < staged.length; n += 1) {
401
+ var ln = staged[n];
402
+ await query(
403
+ "INSERT INTO pick_list_lines (id, list_id, order_id, sku, variant_id, " +
404
+ "expected_quantity, aisle_position, sequence_number) " +
405
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
406
+ [_b().uuid.v7(), listId, ln.order_id, ln.sku, ln.variant_id,
407
+ ln.expected_quantity, ln.aisle_position, n],
408
+ );
409
+ }
410
+ } catch (e) {
411
+ // Compensating cleanup so a partial write doesn't leave a
412
+ // header with no lines on disk. The FK CASCADE on
413
+ // pick_list_lines.list_id means the DELETE on the header
414
+ // also removes any successfully-inserted lines.
415
+ try { await query("DELETE FROM pick_lists WHERE id = ?1", [listId]); }
416
+ catch (_e) { /* drop-silent — the original error is what the operator needs */ }
417
+ throw e;
418
+ }
419
+
420
+ return await _getHydrated(listId);
421
+ },
422
+
423
+ // Read a hydrated list (header + lines) or null on miss. Validates
424
+ // the id shape so a typo surfaces as a clear refusal instead of a
425
+ // bare null that the caller has to disambiguate from "no such list".
426
+ getList: async function (listId) {
427
+ var id = _id(listId, "list_id");
428
+ return await _getHydrated(id);
429
+ },
430
+
431
+ // List headers filtered by location_code and/or status. Returns
432
+ // newest-first (generated_at DESC, id DESC) so the operator UI
433
+ // surfaces fresh worksheets at the top. Lines are NOT hydrated —
434
+ // the listing surface is for picking which list to drill into;
435
+ // the operator calls getList for the lines.
436
+ listLists: async function (listOpts) {
437
+ listOpts = listOpts || {};
438
+ var hasLoc = listOpts.location_code !== undefined && listOpts.location_code !== null;
439
+ var hasStatus = listOpts.status !== undefined && listOpts.status !== null;
440
+ if (hasLoc) _code(listOpts.location_code, "location_code");
441
+ if (hasStatus) _status(listOpts.status);
442
+ var sql, params;
443
+ if (hasLoc && hasStatus) {
444
+ sql = "SELECT * FROM pick_lists WHERE location_code = ?1 AND status = ?2 " +
445
+ "ORDER BY generated_at DESC, id DESC";
446
+ params = [listOpts.location_code, listOpts.status];
447
+ } else if (hasLoc) {
448
+ sql = "SELECT * FROM pick_lists WHERE location_code = ?1 " +
449
+ "ORDER BY generated_at DESC, id DESC";
450
+ params = [listOpts.location_code];
451
+ } else if (hasStatus) {
452
+ sql = "SELECT * FROM pick_lists WHERE status = ?1 " +
453
+ "ORDER BY generated_at DESC, id DESC";
454
+ params = [listOpts.status];
455
+ } else {
456
+ sql = "SELECT * FROM pick_lists ORDER BY generated_at DESC, id DESC";
457
+ params = [];
458
+ }
459
+ return (await query(sql, params)).rows;
460
+ },
461
+
462
+ // Confirm one line. Stamps actual_quantity (defaults to expected
463
+ // when the picker pulled it clean), picker_id, picked_at.
464
+ // Transitions the list generated -> in_progress on the first
465
+ // confirm. Refuses confirms once the list is terminal (complete /
466
+ // cancelled).
467
+ confirmLine: async function (input) {
468
+ if (!input || typeof input !== "object") {
469
+ throw new TypeError("pick-lists.confirmLine: input object required");
470
+ }
471
+ var listId = _id(input.list_id, "list_id");
472
+ var lineId = _id(input.line_id, "line_id");
473
+ _picker(input.picker_id);
474
+ var list = await _getHydrated(listId);
475
+ if (!list) {
476
+ throw new TypeError("pick-lists.confirmLine: list " + listId + " not found");
477
+ }
478
+ if (list.status !== "generated" && list.status !== "in_progress") {
479
+ throw new TypeError("pick-lists.confirmLine: list is " + list.status +
480
+ ", only generated or in_progress lists accept confirms");
481
+ }
482
+ var match = null;
483
+ for (var i = 0; i < list.lines.length; i += 1) {
484
+ if (list.lines[i].id === lineId) { match = list.lines[i]; break; }
485
+ }
486
+ if (!match) {
487
+ throw new TypeError("pick-lists.confirmLine: line " + lineId +
488
+ " not on list " + listId);
489
+ }
490
+ var actual;
491
+ if (input.actual_quantity == null) {
492
+ actual = match.expected_quantity;
493
+ } else {
494
+ _nonNegInt(input.actual_quantity, "actual_quantity");
495
+ actual = input.actual_quantity;
496
+ }
497
+ var ts = _now();
498
+ await query(
499
+ "UPDATE pick_list_lines SET actual_quantity = ?1, picked_by = ?2, picked_at = ?3 " +
500
+ "WHERE id = ?4",
501
+ [actual, input.picker_id, ts, lineId],
502
+ );
503
+ if (list.status === "generated") {
504
+ await query("UPDATE pick_lists SET status = 'in_progress' WHERE id = ?1", [listId]);
505
+ }
506
+ return await _getHydrated(listId);
507
+ },
508
+
509
+ // in_progress -> complete. Refuses if any line is still
510
+ // unconfirmed (actual_quantity IS NULL) — short-picks are settled
511
+ // by confirming with actual_quantity=0, not by leaving the column
512
+ // null. For every parent order represented on the list, calls
513
+ // orderTracking.createShipment exactly once and captures the
514
+ // returned shipment ids on the result so the operator UI can
515
+ // deep-link each one.
516
+ markListComplete: async function (input) {
517
+ if (!input || typeof input !== "object") {
518
+ throw new TypeError("pick-lists.markListComplete: input object required");
519
+ }
520
+ var listId = _id(input.list_id, "list_id");
521
+ var list = await _getHydrated(listId);
522
+ if (!list) {
523
+ throw new TypeError("pick-lists.markListComplete: list " + listId + " not found");
524
+ }
525
+ if (list.status !== "generated" && list.status !== "in_progress") {
526
+ throw new TypeError("pick-lists.markListComplete: list is " + list.status +
527
+ ", only generated or in_progress lists can be completed");
528
+ }
529
+ // Every line must be settled. NULL actual_quantity is the
530
+ // operator forgot — surface loudly.
531
+ for (var i = 0; i < list.lines.length; i += 1) {
532
+ if (list.lines[i].actual_quantity == null) {
533
+ throw new TypeError("pick-lists.markListComplete: line " + list.lines[i].id +
534
+ " (sku " + list.lines[i].sku + ") has not been confirmed");
535
+ }
536
+ }
537
+ // Group lines by parent order_id so each parent gets exactly
538
+ // one shipment. The insertion order is the line-walk order so
539
+ // the shipments table reflects the priority the operator chose
540
+ // when they sorted the list.
541
+ var perOrder = Object.create(null);
542
+ var orderSeq = [];
543
+ for (var k = 0; k < list.lines.length; k += 1) {
544
+ var oid = list.lines[k].order_id;
545
+ if (!Object.prototype.hasOwnProperty.call(perOrder, oid)) {
546
+ perOrder[oid] = [];
547
+ orderSeq.push(oid);
548
+ }
549
+ perOrder[oid].push(list.lines[k]);
550
+ }
551
+ var shipments = [];
552
+ for (var m = 0; m < orderSeq.length; m += 1) {
553
+ var ord = orderSeq[m];
554
+ var s = await orderTracking.createShipment({
555
+ order_id: ord,
556
+ carrier: "pickup",
557
+ notes: "pick-list:" + listId,
558
+ });
559
+ shipments.push({
560
+ order_id: ord,
561
+ shipment_id: s.id,
562
+ });
563
+ }
564
+ var ts = _now();
565
+ await query(
566
+ "UPDATE pick_lists SET status = 'complete', completed_at = ?1 WHERE id = ?2",
567
+ [ts, listId],
568
+ );
569
+ var hydrated = await _getHydrated(listId);
570
+ hydrated.shipments = shipments;
571
+ return hydrated;
572
+ },
573
+
574
+ // generated|in_progress -> cancelled. Persists the reason +
575
+ // cancelled_at. Lines stay on disk so the partial-pick audit
576
+ // trail survives. Refuses cancelling a complete list — the
577
+ // shipments are out the door.
578
+ cancelList: async function (input) {
579
+ if (!input || typeof input !== "object") {
580
+ throw new TypeError("pick-lists.cancelList: input object required");
581
+ }
582
+ var listId = _id(input.list_id, "list_id");
583
+ var reason = _reason(input.reason);
584
+ if (!reason.length) {
585
+ throw new TypeError("pick-lists.cancelList: reason must be a non-empty string");
586
+ }
587
+ var list = await _getHydrated(listId);
588
+ if (!list) {
589
+ throw new TypeError("pick-lists.cancelList: list " + listId + " not found");
590
+ }
591
+ if (list.status !== "generated" && list.status !== "in_progress") {
592
+ throw new TypeError("pick-lists.cancelList: list is " + list.status +
593
+ ", only generated or in_progress lists can be cancelled");
594
+ }
595
+ var ts = _now();
596
+ await query(
597
+ "UPDATE pick_lists SET status = 'cancelled', cancelled_at = ?1, cancel_reason = ?2 " +
598
+ "WHERE id = ?3",
599
+ [ts, reason, listId],
600
+ );
601
+ return await _getHydrated(listId);
602
+ },
603
+
604
+ // Per-line view of (expected, actual, diff). Returns every line
605
+ // on the list — zero-diff included — so the variance report
606
+ // doesn't omit clean picks; the operator can still see which
607
+ // SKUs walked through cleanly alongside the short / over picks.
608
+ // Returns null when the list_id doesn't exist.
609
+ discrepanciesFor: async function (listId) {
610
+ var id = _id(listId, "list_id");
611
+ var list = await _getHydrated(id);
612
+ if (!list) return null;
613
+ var out = [];
614
+ for (var i = 0; i < list.lines.length; i += 1) {
615
+ var line = list.lines[i];
616
+ var actual = line.actual_quantity == null ? null : line.actual_quantity;
617
+ var diff = actual == null ? null : line.expected_quantity - actual;
618
+ out.push({
619
+ line_id: line.id,
620
+ order_id: line.order_id,
621
+ sku: line.sku,
622
+ variant_id: line.variant_id == null ? null : line.variant_id,
623
+ expected_quantity: line.expected_quantity,
624
+ actual_quantity: actual,
625
+ discrepancy: diff,
626
+ aisle_position: line.aisle_position,
627
+ });
628
+ }
629
+ return out;
630
+ },
631
+ };
632
+ }
633
+
634
+ module.exports = {
635
+ create: create,
636
+ LIST_STATUSES: LIST_STATUSES,
637
+ SORT_BY_ENUM: SORT_BY_ENUM,
638
+ ELIGIBLE_ORDER_STATES: ELIGIBLE_ORDER_STATES,
639
+ };