@blamejs/blamejs-shop 0.0.65 → 0.0.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,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
|
+
};
|