@blamejs/blamejs-shop 0.0.62 → 0.0.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/compliance-export.js +614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/email-warmup.js +795 -0
- package/lib/error-log.js +525 -0
- package/lib/index.js +25 -0
- package/lib/invoice-renderer.js +618 -0
- package/lib/live-chat.js +714 -0
- package/lib/metered-usage.js +782 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/split-shipments.js +773 -0
- package/lib/store-credit.js +565 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.cycleCounting
|
|
4
|
+
* @title Cycle counting — scheduled physical inventory audits with
|
|
5
|
+
* variance reconciliation
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Physical stock and recorded stock drift over time. Shrinkage,
|
|
9
|
+
* mis-pick at receive, mis-scan at ship, the occasional
|
|
10
|
+
* theft-from-the-back-room — every shop carries a non-zero gap
|
|
11
|
+
* between "what the database says is on the shelf" and "what is
|
|
12
|
+
* actually on the shelf." Cycle counting is the operator-discipline
|
|
13
|
+
* that closes that gap on a cadence: schedule a count, pick a
|
|
14
|
+
* subset of SKUs, walk the shelf with a worksheet, record the
|
|
15
|
+
* actual qty, write adjustments for the variances.
|
|
16
|
+
*
|
|
17
|
+
* This primitive owns the scheduling + worksheet + variance math.
|
|
18
|
+
* It composes `inventoryLocations.adjustStock` for the per-shelf
|
|
19
|
+
* correction (when wired) so the audit trail on
|
|
20
|
+
* `inventory_adjustments` carries the cycle-count slug as the
|
|
21
|
+
* reason — operators reconstruct "which count produced this
|
|
22
|
+
* shrinkage write-off" from a single column.
|
|
23
|
+
*
|
|
24
|
+
* Lifecycle (four-state FSM):
|
|
25
|
+
*
|
|
26
|
+
* defineCount({ slug, kind, scope, scheduled_at, location_code? })
|
|
27
|
+
* Validates the scope payload against the kind enum, persists
|
|
28
|
+
* the header at status='scheduled'. No worksheet rows yet —
|
|
29
|
+
* worksheetFor pulls expected quantities at call time so a
|
|
30
|
+
* count scheduled today and walked next Monday captures the
|
|
31
|
+
* Monday-morning expected qty rather than today's.
|
|
32
|
+
*
|
|
33
|
+
* worksheetFor(count_slug)
|
|
34
|
+
* Resolves the scope into an SKU list (rotating: scope.skus;
|
|
35
|
+
* abc: catalog.skusForAbcClass(scope.abc_class); full:
|
|
36
|
+
* catalog.allActiveSkus()). For every SKU pulls the expected
|
|
37
|
+
* quantity (global from catalog.inventory.get(sku) when
|
|
38
|
+
* location_code is null, per-location from
|
|
39
|
+
* inventoryLocations.stockForSku(sku) otherwise) and persists
|
|
40
|
+
* one line per SKU. Transitions scheduled -> in_progress on
|
|
41
|
+
* first call; subsequent calls are idempotent (return the
|
|
42
|
+
* same worksheet rows).
|
|
43
|
+
*
|
|
44
|
+
* recordCount({ slug, lines: [{ sku, location_code?, actual_quantity, counted_by }] })
|
|
45
|
+
* Patches the actual_quantity + counted_by + counted_at
|
|
46
|
+
* columns on existing line rows. Refuses SKUs that weren't on
|
|
47
|
+
* the worksheet (the operator must defineCount + worksheetFor
|
|
48
|
+
* first). Idempotent on the per-SKU value — calling recordCount
|
|
49
|
+
* twice for the same SKU overwrites the prior actual_quantity
|
|
50
|
+
* (a recount). counted_at defaults to now.
|
|
51
|
+
*
|
|
52
|
+
* finalizeCount({ slug, apply_adjustments? })
|
|
53
|
+
* in_progress -> finalized. Walks every line, computes
|
|
54
|
+
* variance = actual_quantity - expected_quantity (lines with
|
|
55
|
+
* NULL actual_quantity are treated as actual=0 — the operator
|
|
56
|
+
* didn't count it, every expected unit is a discrepancy).
|
|
57
|
+
* Writes the variance column on every line. Aggregates
|
|
58
|
+
* variance_count (lines with non-zero variance) and
|
|
59
|
+
* variance_value_minor (sum of |variance| * unit_value) onto
|
|
60
|
+
* the header. When apply_adjustments is true and an
|
|
61
|
+
* inventoryLocations dep was wired, calls adjustStock for
|
|
62
|
+
* every non-zero variance with reason "cycle-count:<slug>".
|
|
63
|
+
* Returns { variance_count, variance_value_minor,
|
|
64
|
+
* adjustments_written }.
|
|
65
|
+
*
|
|
66
|
+
* cancelCount({ slug, reason })
|
|
67
|
+
* scheduled|in_progress -> cancelled. Persists the reason +
|
|
68
|
+
* cancelled_at timestamp. Cancelling a finalized count is
|
|
69
|
+
* refused — the adjustments have already landed.
|
|
70
|
+
*
|
|
71
|
+
* Reads:
|
|
72
|
+
* discrepanciesFor(slug) — every line with non-zero variance
|
|
73
|
+
* listCounts({ kind?, from?, to? })
|
|
74
|
+
* — keyset-paginated count headers
|
|
75
|
+
* historyForSku(sku, { limit, cursor? })
|
|
76
|
+
* — every line across every count
|
|
77
|
+
* that touched this SKU
|
|
78
|
+
*
|
|
79
|
+
* Composition:
|
|
80
|
+
* - b.uuid.v7 — line row PKs (sortable)
|
|
81
|
+
* - b.pagination — HMAC-tagged cursors for the list verbs
|
|
82
|
+
* - catalog — SOLE source of SKU enumeration for
|
|
83
|
+
* abc + full scopes; global-stock reads
|
|
84
|
+
* for counts without a location_code;
|
|
85
|
+
* optional unitValueMinor(sku) for the
|
|
86
|
+
* variance_value_minor aggregation
|
|
87
|
+
* - inventoryLocations — optional. Required when the count
|
|
88
|
+
* carries a location_code so per-shelf
|
|
89
|
+
* expected qty is captured; required
|
|
90
|
+
* when finalizeCount({ apply_adjustments:
|
|
91
|
+
* true }) is called so the variance
|
|
92
|
+
* writes through adjustStock.
|
|
93
|
+
*
|
|
94
|
+
* Three-tier input validation: every public verb here is either a
|
|
95
|
+
* config-time entry point (defineCount, cancelCount) or a
|
|
96
|
+
* defensive request-shape reader (worksheetFor, recordCount,
|
|
97
|
+
* finalizeCount, discrepanciesFor, listCounts, historyForSku).
|
|
98
|
+
* Both shapes throw on bad input — no drop-silent hot-path sinks.
|
|
99
|
+
*
|
|
100
|
+
* @primitive cycleCounting
|
|
101
|
+
* @related inventoryLocations, stockTransfers, catalog
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
var bShop;
|
|
105
|
+
function _b() {
|
|
106
|
+
if (!bShop) bShop = require("./index");
|
|
107
|
+
return bShop.framework;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- constants ----------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,79}$/;
|
|
113
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
114
|
+
var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
115
|
+
var ABC_RE = /^[A-Za-z][A-Za-z0-9]{0,15}$/;
|
|
116
|
+
var COUNTER_RE = /^[\S\s]{1,128}$/;
|
|
117
|
+
var KINDS = Object.freeze(["rotating", "abc", "full"]);
|
|
118
|
+
var STATUSES = Object.freeze(["scheduled", "in_progress", "finalized", "cancelled"]);
|
|
119
|
+
var MAX_REASON = 280;
|
|
120
|
+
var MAX_LINES = 50000;
|
|
121
|
+
var MAX_LIST_LIMIT = 200;
|
|
122
|
+
var LINE_ORDER_KEY = ["counted_at:desc", "id:desc"];
|
|
123
|
+
|
|
124
|
+
// ---- validators ---------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
function _slug(s) {
|
|
127
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
128
|
+
throw new TypeError("cycle-counting: slug must match /^[a-z0-9][a-z0-9-]*$/ (lowercase alnum + dash, 1..80 chars)");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function _sku(s) {
|
|
132
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
133
|
+
throw new TypeError("cycle-counting: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function _code(s, label) {
|
|
137
|
+
if (typeof s !== "string" || !CODE_RE.test(s)) {
|
|
138
|
+
throw new TypeError("cycle-counting: " + (label || "location_code") +
|
|
139
|
+
" must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function _codeOrNull(s, label) {
|
|
143
|
+
if (s == null) return null;
|
|
144
|
+
_code(s, label);
|
|
145
|
+
return s;
|
|
146
|
+
}
|
|
147
|
+
function _abc(s) {
|
|
148
|
+
if (typeof s !== "string" || !ABC_RE.test(s)) {
|
|
149
|
+
throw new TypeError("cycle-counting: abc_class must match /^[A-Za-z][A-Za-z0-9]*$/ (1..16 chars)");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function _kind(s) {
|
|
153
|
+
if (KINDS.indexOf(s) === -1) {
|
|
154
|
+
throw new TypeError("cycle-counting: kind must be one of " + KINDS.join(", ") +
|
|
155
|
+
", got " + JSON.stringify(s));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function _epochMs(n, label) {
|
|
159
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
160
|
+
throw new TypeError("cycle-counting: " + label + " must be a non-negative integer (epoch ms)");
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function _nonNegInt(n, label) {
|
|
164
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
165
|
+
throw new TypeError("cycle-counting: " + label + " must be a non-negative integer");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function _counter(s) {
|
|
169
|
+
if (s == null) return null;
|
|
170
|
+
if (typeof s !== "string" || !COUNTER_RE.test(s) || s.length > 128) {
|
|
171
|
+
throw new TypeError("cycle-counting: counted_by must be a string ≤ 128 chars");
|
|
172
|
+
}
|
|
173
|
+
return s;
|
|
174
|
+
}
|
|
175
|
+
function _reason(s) {
|
|
176
|
+
if (s == null) return "";
|
|
177
|
+
if (typeof s !== "string" || s.length > MAX_REASON) {
|
|
178
|
+
throw new TypeError("cycle-counting: reason must be a string ≤ " + MAX_REASON + " chars");
|
|
179
|
+
}
|
|
180
|
+
return s;
|
|
181
|
+
}
|
|
182
|
+
function _limit(n) {
|
|
183
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
184
|
+
throw new TypeError("cycle-counting: limit must be an integer in 1..." + MAX_LIST_LIMIT);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function _now() { return Date.now(); }
|
|
189
|
+
|
|
190
|
+
// Parse the operator-supplied `scope` against the kind enum. Returns
|
|
191
|
+
// a normalized object: { skus: [...] } | { abc_class: string } |
|
|
192
|
+
// { all_active: true }. Refuses cross-kind payloads (an abc count
|
|
193
|
+
// with an `skus` array, etc.) so misconfiguration surfaces at
|
|
194
|
+
// defineCount time rather than at worksheetFor.
|
|
195
|
+
function _validateScope(kind, scope) {
|
|
196
|
+
if (!scope || typeof scope !== "object") {
|
|
197
|
+
throw new TypeError("cycle-counting: scope must be an object");
|
|
198
|
+
}
|
|
199
|
+
if (kind === "rotating") {
|
|
200
|
+
if (!Array.isArray(scope.skus) || scope.skus.length === 0) {
|
|
201
|
+
throw new TypeError("cycle-counting: kind=rotating requires scope.skus as a non-empty array");
|
|
202
|
+
}
|
|
203
|
+
if (scope.skus.length > MAX_LINES) {
|
|
204
|
+
throw new TypeError("cycle-counting: scope.skus must contain ≤ " + MAX_LINES + " entries");
|
|
205
|
+
}
|
|
206
|
+
var seen = Object.create(null);
|
|
207
|
+
var normalized = [];
|
|
208
|
+
for (var i = 0; i < scope.skus.length; i += 1) {
|
|
209
|
+
_sku(scope.skus[i]);
|
|
210
|
+
if (seen[scope.skus[i]]) {
|
|
211
|
+
throw new TypeError("cycle-counting: duplicate sku " + JSON.stringify(scope.skus[i]) +
|
|
212
|
+
" in scope.skus");
|
|
213
|
+
}
|
|
214
|
+
seen[scope.skus[i]] = true;
|
|
215
|
+
normalized.push(scope.skus[i]);
|
|
216
|
+
}
|
|
217
|
+
return { skus: normalized };
|
|
218
|
+
}
|
|
219
|
+
if (kind === "abc") {
|
|
220
|
+
if (scope.abc_class == null) {
|
|
221
|
+
throw new TypeError("cycle-counting: kind=abc requires scope.abc_class");
|
|
222
|
+
}
|
|
223
|
+
_abc(scope.abc_class);
|
|
224
|
+
return { abc_class: scope.abc_class };
|
|
225
|
+
}
|
|
226
|
+
// kind === "full"
|
|
227
|
+
if (scope.all_active !== true) {
|
|
228
|
+
throw new TypeError("cycle-counting: kind=full requires scope.all_active === true");
|
|
229
|
+
}
|
|
230
|
+
return { all_active: true };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---- factory ------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
function create(opts) {
|
|
236
|
+
opts = opts || {};
|
|
237
|
+
if (!opts.catalog || typeof opts.catalog !== "object") {
|
|
238
|
+
throw new TypeError("cycle-counting.create: opts.catalog is required");
|
|
239
|
+
}
|
|
240
|
+
var catalog = opts.catalog;
|
|
241
|
+
// inventoryLocations is optional — required only at run time when
|
|
242
|
+
// either (a) the count carries a location_code (per-shelf expected
|
|
243
|
+
// qty + per-shelf adjustment), or (b) finalizeCount({
|
|
244
|
+
// apply_adjustments: true }) is called. The runtime check surfaces
|
|
245
|
+
// a typed error at the call site rather than failing loud at boot
|
|
246
|
+
// for operators that only run global counts.
|
|
247
|
+
var inventoryLocations = opts.inventoryLocations || null;
|
|
248
|
+
if (inventoryLocations !== null && typeof inventoryLocations !== "object") {
|
|
249
|
+
throw new TypeError("cycle-counting.create: opts.inventoryLocations must be an object or null");
|
|
250
|
+
}
|
|
251
|
+
var query = opts.query;
|
|
252
|
+
if (!query) {
|
|
253
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
254
|
+
}
|
|
255
|
+
// Pagination cursors are HMAC-tagged so operators can't tamper
|
|
256
|
+
// across deployments. Tests inject a fixed dev string; production
|
|
257
|
+
// demands an explicit secret.
|
|
258
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
259
|
+
if (process.env.NODE_ENV === "production") {
|
|
260
|
+
throw new Error("cycle-counting.create: opts.cursorSecret is required in production");
|
|
261
|
+
}
|
|
262
|
+
opts.cursorSecret = "cycle-counting-cursor-secret-dev-only";
|
|
263
|
+
}
|
|
264
|
+
var cursorSecret = opts.cursorSecret;
|
|
265
|
+
|
|
266
|
+
// Read the count header or null on miss.
|
|
267
|
+
async function _getHeader(slug) {
|
|
268
|
+
var r = await query("SELECT * FROM cycle_counts WHERE slug = ?1", [slug]);
|
|
269
|
+
return r.rows[0] || null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Read every line for a count, ordered by SKU so the worksheet
|
|
273
|
+
// walks the shelf in a stable order.
|
|
274
|
+
async function _getLines(slug) {
|
|
275
|
+
var r = await query(
|
|
276
|
+
"SELECT * FROM cycle_count_lines WHERE count_slug = ?1 ORDER BY sku ASC",
|
|
277
|
+
[slug],
|
|
278
|
+
);
|
|
279
|
+
return r.rows;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Hydrate a header + its lines + its parsed scope. Returns null on
|
|
283
|
+
// miss so callers map cleanly to HTTP 404.
|
|
284
|
+
async function _getHydrated(slug) {
|
|
285
|
+
var header = await _getHeader(slug);
|
|
286
|
+
if (!header) return null;
|
|
287
|
+
header.scope = JSON.parse(header.scope_json);
|
|
288
|
+
header.lines = await _getLines(slug);
|
|
289
|
+
return header;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Resolve the count's scope into an SKU list. Composes the catalog
|
|
293
|
+
// for the abc + full kinds; the rotating kind is just the persisted
|
|
294
|
+
// scope.skus array.
|
|
295
|
+
async function _resolveSkus(scope) {
|
|
296
|
+
if (scope.skus) return scope.skus.slice();
|
|
297
|
+
if (scope.abc_class) {
|
|
298
|
+
if (typeof catalog.skusForAbcClass !== "function") {
|
|
299
|
+
throw new TypeError("cycle-counting.worksheetFor: kind=abc requires " +
|
|
300
|
+
"catalog.skusForAbcClass(class) but the wired catalog does not expose it");
|
|
301
|
+
}
|
|
302
|
+
var fromAbc = await catalog.skusForAbcClass(scope.abc_class);
|
|
303
|
+
if (!Array.isArray(fromAbc)) {
|
|
304
|
+
throw new TypeError("cycle-counting.worksheetFor: catalog.skusForAbcClass(" +
|
|
305
|
+
JSON.stringify(scope.abc_class) + ") must return an array");
|
|
306
|
+
}
|
|
307
|
+
return fromAbc.slice();
|
|
308
|
+
}
|
|
309
|
+
// scope.all_active === true
|
|
310
|
+
if (typeof catalog.allActiveSkus !== "function") {
|
|
311
|
+
throw new TypeError("cycle-counting.worksheetFor: kind=full requires " +
|
|
312
|
+
"catalog.allActiveSkus() but the wired catalog does not expose it");
|
|
313
|
+
}
|
|
314
|
+
var all = await catalog.allActiveSkus();
|
|
315
|
+
if (!Array.isArray(all)) {
|
|
316
|
+
throw new TypeError("cycle-counting.worksheetFor: catalog.allActiveSkus() must return an array");
|
|
317
|
+
}
|
|
318
|
+
return all.slice();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Read the expected quantity for (sku, location_code). Global path
|
|
322
|
+
// hits catalog.inventory.get(sku); per-location path requires the
|
|
323
|
+
// inventoryLocations dep and reads stockForSku.
|
|
324
|
+
async function _expectedQty(sku, locationCode) {
|
|
325
|
+
if (locationCode == null) {
|
|
326
|
+
if (!catalog.inventory || typeof catalog.inventory.get !== "function") {
|
|
327
|
+
throw new TypeError("cycle-counting: catalog.inventory.get(sku) is required " +
|
|
328
|
+
"for counts without a location_code");
|
|
329
|
+
}
|
|
330
|
+
var inv = await catalog.inventory.get(sku);
|
|
331
|
+
if (!inv) return 0;
|
|
332
|
+
return Number(inv.stock_on_hand) || 0;
|
|
333
|
+
}
|
|
334
|
+
if (!inventoryLocations) {
|
|
335
|
+
throw new TypeError("cycle-counting: count carries location_code " +
|
|
336
|
+
JSON.stringify(locationCode) +
|
|
337
|
+
" but opts.inventoryLocations was not wired into the factory");
|
|
338
|
+
}
|
|
339
|
+
var stock = await inventoryLocations.stockForSku(sku);
|
|
340
|
+
var locs = (stock && stock.by_location) || [];
|
|
341
|
+
for (var i = 0; i < locs.length; i += 1) {
|
|
342
|
+
if (locs[i].code === locationCode) return Number(locs[i].quantity) || 0;
|
|
343
|
+
}
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Optional unit-value read for the variance_value_minor aggregation.
|
|
348
|
+
// Catalogs that don't expose unitValueMinor get a 0 fallback — the
|
|
349
|
+
// variance count + per-line variance ints still carry the operator
|
|
350
|
+
// signal; only the monetary roll-up collapses.
|
|
351
|
+
async function _unitValueMinor(sku) {
|
|
352
|
+
if (typeof catalog.unitValueMinor !== "function") return 0;
|
|
353
|
+
var v = await catalog.unitValueMinor(sku);
|
|
354
|
+
if (v == null) return 0;
|
|
355
|
+
if (!Number.isInteger(v) || v < 0) {
|
|
356
|
+
throw new TypeError("cycle-counting: catalog.unitValueMinor(" + JSON.stringify(sku) +
|
|
357
|
+
") must return a non-negative integer or null, got " + JSON.stringify(v));
|
|
358
|
+
}
|
|
359
|
+
return v;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
|
|
364
|
+
// Exposed for tests + admin dashboards.
|
|
365
|
+
KINDS: KINDS,
|
|
366
|
+
STATUSES: STATUSES,
|
|
367
|
+
|
|
368
|
+
// Create a new scheduled count. The worksheet is NOT materialized
|
|
369
|
+
// until worksheetFor is called — a count scheduled today for next
|
|
370
|
+
// Monday captures the Monday-morning expected qty, not today's.
|
|
371
|
+
defineCount: async function (input) {
|
|
372
|
+
if (!input || typeof input !== "object") {
|
|
373
|
+
throw new TypeError("cycle-counting.defineCount: input object required");
|
|
374
|
+
}
|
|
375
|
+
_slug(input.slug);
|
|
376
|
+
_kind(input.kind);
|
|
377
|
+
var scope = _validateScope(input.kind, input.scope);
|
|
378
|
+
_epochMs(input.scheduled_at, "scheduled_at");
|
|
379
|
+
var locationCode = _codeOrNull(input.location_code, "location_code");
|
|
380
|
+
|
|
381
|
+
// Refuse redefine. Operators that hit this should cancel the
|
|
382
|
+
// prior count (cancelCount) or pick a new slug — silent
|
|
383
|
+
// overwrite would clobber the variance numbers on a finalized
|
|
384
|
+
// count and is never what the operator wants.
|
|
385
|
+
var existing = await _getHeader(input.slug);
|
|
386
|
+
if (existing) {
|
|
387
|
+
throw new TypeError("cycle-counting.defineCount: slug " +
|
|
388
|
+
JSON.stringify(input.slug) + " already exists (status " + existing.status +
|
|
389
|
+
") — cancel or pick a new slug");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
var ts = _now();
|
|
393
|
+
await query(
|
|
394
|
+
"INSERT INTO cycle_counts (slug, kind, scope_json, scheduled_at, location_code, " +
|
|
395
|
+
"status, variance_count, variance_value_minor, finalized_at, cancelled_at, " +
|
|
396
|
+
"cancel_reason, created_at) " +
|
|
397
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 'scheduled', NULL, NULL, NULL, NULL, NULL, ?6)",
|
|
398
|
+
[input.slug, input.kind, JSON.stringify(scope), input.scheduled_at, locationCode, ts],
|
|
399
|
+
);
|
|
400
|
+
return await _getHydrated(input.slug);
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
// Resolve the scope into an SKU list, persist one line per SKU
|
|
404
|
+
// with the current expected qty, transition scheduled ->
|
|
405
|
+
// in_progress. Idempotent: a second call after worksheet rows
|
|
406
|
+
// already exist returns those rows untouched (the operator is
|
|
407
|
+
// re-reading the worksheet, not re-deriving expected qtys).
|
|
408
|
+
worksheetFor: async function (countSlug) {
|
|
409
|
+
_slug(countSlug);
|
|
410
|
+
var header = await _getHeader(countSlug);
|
|
411
|
+
if (!header) {
|
|
412
|
+
throw new TypeError("cycle-counting.worksheetFor: count " +
|
|
413
|
+
JSON.stringify(countSlug) + " not found");
|
|
414
|
+
}
|
|
415
|
+
if (header.status === "cancelled" || header.status === "finalized") {
|
|
416
|
+
throw new TypeError("cycle-counting.worksheetFor: count is " + header.status +
|
|
417
|
+
", worksheet only available for scheduled or in_progress counts");
|
|
418
|
+
}
|
|
419
|
+
var existingLines = await _getLines(countSlug);
|
|
420
|
+
if (existingLines.length > 0) {
|
|
421
|
+
// Idempotent: the worksheet has already been materialized.
|
|
422
|
+
// Re-reading is a frequent operator move (the dashboard
|
|
423
|
+
// refreshes); preserving the captured expected_quantity is
|
|
424
|
+
// what we want.
|
|
425
|
+
return existingLines;
|
|
426
|
+
}
|
|
427
|
+
var scope = JSON.parse(header.scope_json);
|
|
428
|
+
var skus = await _resolveSkus(scope);
|
|
429
|
+
if (skus.length === 0) {
|
|
430
|
+
// Edge case: an abc count where no SKU carries that class,
|
|
431
|
+
// or a fresh catalog with no active SKUs for a full count.
|
|
432
|
+
// Persist no lines but still transition status — the operator
|
|
433
|
+
// sees an empty worksheet and finalizes a zero-variance count.
|
|
434
|
+
await query(
|
|
435
|
+
"UPDATE cycle_counts SET status = 'in_progress' WHERE slug = ?1",
|
|
436
|
+
[countSlug],
|
|
437
|
+
);
|
|
438
|
+
return [];
|
|
439
|
+
}
|
|
440
|
+
// De-duplicate the catalog-resolved SKU list. The operator's
|
|
441
|
+
// scope.skus is already deduped at defineCount; the catalog
|
|
442
|
+
// adapters are not contractually deduped, so we defend here.
|
|
443
|
+
var seen = Object.create(null);
|
|
444
|
+
var deduped = [];
|
|
445
|
+
for (var d = 0; d < skus.length; d += 1) {
|
|
446
|
+
_sku(skus[d]);
|
|
447
|
+
if (seen[skus[d]]) continue;
|
|
448
|
+
seen[skus[d]] = true;
|
|
449
|
+
deduped.push(skus[d]);
|
|
450
|
+
}
|
|
451
|
+
for (var i = 0; i < deduped.length; i += 1) {
|
|
452
|
+
var expected = await _expectedQty(deduped[i], header.location_code);
|
|
453
|
+
await query(
|
|
454
|
+
"INSERT INTO cycle_count_lines (id, count_slug, sku, location_code, " +
|
|
455
|
+
"expected_quantity, actual_quantity, counted_by, counted_at, variance) " +
|
|
456
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, NULL, NULL, NULL, NULL)",
|
|
457
|
+
[_b().uuid.v7(), countSlug, deduped[i], header.location_code, expected],
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
await query(
|
|
461
|
+
"UPDATE cycle_counts SET status = 'in_progress' WHERE slug = ?1",
|
|
462
|
+
[countSlug],
|
|
463
|
+
);
|
|
464
|
+
return await _getLines(countSlug);
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
// Record the operator-captured actual qty for one or more lines.
|
|
468
|
+
// Refuses SKUs not on the worksheet — the operator must call
|
|
469
|
+
// defineCount + worksheetFor before recording. Repeated calls for
|
|
470
|
+
// the same SKU overwrite the prior actual_quantity (a recount).
|
|
471
|
+
recordCount: async function (input) {
|
|
472
|
+
if (!input || typeof input !== "object") {
|
|
473
|
+
throw new TypeError("cycle-counting.recordCount: input object required");
|
|
474
|
+
}
|
|
475
|
+
_slug(input.slug);
|
|
476
|
+
if (!Array.isArray(input.lines) || input.lines.length === 0) {
|
|
477
|
+
throw new TypeError("cycle-counting.recordCount: lines must be a non-empty array");
|
|
478
|
+
}
|
|
479
|
+
if (input.lines.length > MAX_LINES) {
|
|
480
|
+
throw new TypeError("cycle-counting.recordCount: lines must contain ≤ " + MAX_LINES + " entries");
|
|
481
|
+
}
|
|
482
|
+
var header = await _getHeader(input.slug);
|
|
483
|
+
if (!header) {
|
|
484
|
+
throw new TypeError("cycle-counting.recordCount: count " +
|
|
485
|
+
JSON.stringify(input.slug) + " not found");
|
|
486
|
+
}
|
|
487
|
+
if (header.status !== "scheduled" && header.status !== "in_progress") {
|
|
488
|
+
throw new TypeError("cycle-counting.recordCount: count is " + header.status +
|
|
489
|
+
", only scheduled or in_progress counts accept recordings");
|
|
490
|
+
}
|
|
491
|
+
// Validate every line up front so a partial write doesn't land
|
|
492
|
+
// on a typo halfway through the batch.
|
|
493
|
+
var normalized = [];
|
|
494
|
+
var seen = Object.create(null);
|
|
495
|
+
for (var i = 0; i < input.lines.length; i += 1) {
|
|
496
|
+
var l = input.lines[i];
|
|
497
|
+
if (!l || typeof l !== "object") {
|
|
498
|
+
throw new TypeError("cycle-counting.recordCount: lines[" + i + "] must be an object");
|
|
499
|
+
}
|
|
500
|
+
_sku(l.sku);
|
|
501
|
+
_nonNegInt(l.actual_quantity, "lines[" + i + "].actual_quantity");
|
|
502
|
+
var lineLoc = _codeOrNull(l.location_code, "lines[" + i + "].location_code");
|
|
503
|
+
var countedBy = _counter(l.counted_by);
|
|
504
|
+
var key = l.sku + "\x00" + (lineLoc || "");
|
|
505
|
+
if (seen[key]) {
|
|
506
|
+
throw new TypeError("cycle-counting.recordCount: duplicate (sku, location_code) " +
|
|
507
|
+
JSON.stringify({ sku: l.sku, location_code: lineLoc }) + " in lines");
|
|
508
|
+
}
|
|
509
|
+
seen[key] = true;
|
|
510
|
+
normalized.push({
|
|
511
|
+
sku: l.sku,
|
|
512
|
+
location_code: lineLoc,
|
|
513
|
+
actual_quantity: l.actual_quantity,
|
|
514
|
+
counted_by: countedBy,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
// Cross-check every recorded SKU is on the worksheet. The
|
|
518
|
+
// worksheet is keyed by (count_slug, sku, location_code) — a
|
|
519
|
+
// location-scoped count has location_code on every line; a
|
|
520
|
+
// global count has location_code NULL on every line. The
|
|
521
|
+
// operator passing a location_code that doesn't match the
|
|
522
|
+
// header is a typo we surface up front.
|
|
523
|
+
var lines = await _getLines(input.slug);
|
|
524
|
+
var index = Object.create(null);
|
|
525
|
+
for (var w = 0; w < lines.length; w += 1) {
|
|
526
|
+
var keyW = lines[w].sku + "\x00" + (lines[w].location_code || "");
|
|
527
|
+
index[keyW] = lines[w];
|
|
528
|
+
}
|
|
529
|
+
for (var r2 = 0; r2 < normalized.length; r2 += 1) {
|
|
530
|
+
var keyR = normalized[r2].sku + "\x00" + (normalized[r2].location_code || "");
|
|
531
|
+
if (!index[keyR]) {
|
|
532
|
+
throw new TypeError("cycle-counting.recordCount: sku " +
|
|
533
|
+
JSON.stringify(normalized[r2].sku) +
|
|
534
|
+
(normalized[r2].location_code
|
|
535
|
+
? " at location " + JSON.stringify(normalized[r2].location_code)
|
|
536
|
+
: "") +
|
|
537
|
+
" is not on the worksheet for count " + JSON.stringify(input.slug) +
|
|
538
|
+
" — call worksheetFor first");
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
var ts = _now();
|
|
542
|
+
for (var u = 0; u < normalized.length; u += 1) {
|
|
543
|
+
var entry = normalized[u];
|
|
544
|
+
var existing = index[entry.sku + "\x00" + (entry.location_code || "")];
|
|
545
|
+
await query(
|
|
546
|
+
"UPDATE cycle_count_lines SET actual_quantity = ?1, counted_by = ?2, " +
|
|
547
|
+
"counted_at = ?3 WHERE id = ?4",
|
|
548
|
+
[entry.actual_quantity, entry.counted_by, ts, existing.id],
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
return await _getLines(input.slug);
|
|
552
|
+
},
|
|
553
|
+
|
|
554
|
+
// Compute per-line variance, aggregate to the header, optionally
|
|
555
|
+
// write per-shelf adjustments via inventoryLocations.adjustStock.
|
|
556
|
+
// Returns the aggregated numbers + adjustments_written count so
|
|
557
|
+
// the caller doesn't re-read the header.
|
|
558
|
+
finalizeCount: async function (input) {
|
|
559
|
+
if (!input || typeof input !== "object") {
|
|
560
|
+
throw new TypeError("cycle-counting.finalizeCount: input object required");
|
|
561
|
+
}
|
|
562
|
+
_slug(input.slug);
|
|
563
|
+
var applyAdjustments = false;
|
|
564
|
+
if (input.apply_adjustments != null) {
|
|
565
|
+
if (typeof input.apply_adjustments !== "boolean") {
|
|
566
|
+
throw new TypeError("cycle-counting.finalizeCount: apply_adjustments must be a boolean");
|
|
567
|
+
}
|
|
568
|
+
applyAdjustments = input.apply_adjustments;
|
|
569
|
+
}
|
|
570
|
+
if (applyAdjustments && !inventoryLocations) {
|
|
571
|
+
throw new TypeError("cycle-counting.finalizeCount: apply_adjustments=true requires " +
|
|
572
|
+
"opts.inventoryLocations to be wired into the factory");
|
|
573
|
+
}
|
|
574
|
+
var header = await _getHeader(input.slug);
|
|
575
|
+
if (!header) {
|
|
576
|
+
throw new TypeError("cycle-counting.finalizeCount: count " +
|
|
577
|
+
JSON.stringify(input.slug) + " not found");
|
|
578
|
+
}
|
|
579
|
+
if (header.status !== "scheduled" && header.status !== "in_progress") {
|
|
580
|
+
throw new TypeError("cycle-counting.finalizeCount: count is " + header.status +
|
|
581
|
+
", only scheduled or in_progress counts can be finalized");
|
|
582
|
+
}
|
|
583
|
+
var lines = await _getLines(input.slug);
|
|
584
|
+
var varianceCount = 0;
|
|
585
|
+
var varianceValueMinor = 0;
|
|
586
|
+
var adjustmentsWritten = 0;
|
|
587
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
588
|
+
var line = lines[i];
|
|
589
|
+
// NULL actual_quantity = operator didn't count this line.
|
|
590
|
+
// Treat as 0 — every expected unit is a discrepancy. This is
|
|
591
|
+
// the same posture as stock-transfers receiving an empty
|
|
592
|
+
// received_lines array: silent omission must surface loud.
|
|
593
|
+
var actual = line.actual_quantity == null ? 0 : line.actual_quantity;
|
|
594
|
+
var variance = actual - line.expected_quantity;
|
|
595
|
+
await query(
|
|
596
|
+
"UPDATE cycle_count_lines SET variance = ?1 WHERE id = ?2",
|
|
597
|
+
[variance, line.id],
|
|
598
|
+
);
|
|
599
|
+
if (variance !== 0) {
|
|
600
|
+
varianceCount += 1;
|
|
601
|
+
var unitValue = await _unitValueMinor(line.sku);
|
|
602
|
+
varianceValueMinor += Math.abs(variance) * unitValue;
|
|
603
|
+
if (applyAdjustments) {
|
|
604
|
+
// Adjustments only land on lines that carry a location_code.
|
|
605
|
+
// A global count (no location_code on any line) computes
|
|
606
|
+
// variance for the operator's reconciliation report but
|
|
607
|
+
// does NOT write to inventory_locations — the operator's
|
|
608
|
+
// catalog-side correction (catalog.inventory.restock /
|
|
609
|
+
// release / setStock) is the right verb for the single-
|
|
610
|
+
// bucket inventory.
|
|
611
|
+
if (line.location_code) {
|
|
612
|
+
await inventoryLocations.adjustStock({
|
|
613
|
+
sku: line.sku,
|
|
614
|
+
location_code: line.location_code,
|
|
615
|
+
delta: variance,
|
|
616
|
+
reason: "cycle-count:" + input.slug,
|
|
617
|
+
});
|
|
618
|
+
adjustmentsWritten += 1;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
var ts = _now();
|
|
624
|
+
await query(
|
|
625
|
+
"UPDATE cycle_counts SET status = 'finalized', variance_count = ?1, " +
|
|
626
|
+
"variance_value_minor = ?2, finalized_at = ?3 WHERE slug = ?4",
|
|
627
|
+
[varianceCount, varianceValueMinor, ts, input.slug],
|
|
628
|
+
);
|
|
629
|
+
return {
|
|
630
|
+
variance_count: varianceCount,
|
|
631
|
+
variance_value_minor: varianceValueMinor,
|
|
632
|
+
adjustments_written: adjustmentsWritten,
|
|
633
|
+
};
|
|
634
|
+
},
|
|
635
|
+
|
|
636
|
+
// Abandon a non-terminal count. The header survives so any
|
|
637
|
+
// dashboard read that captured the slug still resolves; the line
|
|
638
|
+
// rows survive too (the FK is CASCADE on DELETE, not on
|
|
639
|
+
// cancellation) so operators can read the partial recordings as
|
|
640
|
+
// forensic data.
|
|
641
|
+
cancelCount: async function (input) {
|
|
642
|
+
if (!input || typeof input !== "object") {
|
|
643
|
+
throw new TypeError("cycle-counting.cancelCount: input object required");
|
|
644
|
+
}
|
|
645
|
+
_slug(input.slug);
|
|
646
|
+
var reason = _reason(input.reason);
|
|
647
|
+
if (!reason.length) {
|
|
648
|
+
throw new TypeError("cycle-counting.cancelCount: reason must be a non-empty string");
|
|
649
|
+
}
|
|
650
|
+
var header = await _getHeader(input.slug);
|
|
651
|
+
if (!header) {
|
|
652
|
+
throw new TypeError("cycle-counting.cancelCount: count " +
|
|
653
|
+
JSON.stringify(input.slug) + " not found");
|
|
654
|
+
}
|
|
655
|
+
if (header.status === "finalized" || header.status === "cancelled") {
|
|
656
|
+
throw new TypeError("cycle-counting.cancelCount: count is " + header.status +
|
|
657
|
+
", terminal states cannot be cancelled");
|
|
658
|
+
}
|
|
659
|
+
var ts = _now();
|
|
660
|
+
await query(
|
|
661
|
+
"UPDATE cycle_counts SET status = 'cancelled', cancelled_at = ?1, " +
|
|
662
|
+
"cancel_reason = ?2 WHERE slug = ?3",
|
|
663
|
+
[ts, reason, input.slug],
|
|
664
|
+
);
|
|
665
|
+
return await _getHydrated(input.slug);
|
|
666
|
+
},
|
|
667
|
+
|
|
668
|
+
// Per-line variance report. Returns every line with non-zero
|
|
669
|
+
// variance — the operator's reconciliation worksheet. Lines with
|
|
670
|
+
// NULL variance (count not yet finalized) are filtered out.
|
|
671
|
+
discrepanciesFor: async function (countSlug) {
|
|
672
|
+
_slug(countSlug);
|
|
673
|
+
var header = await _getHeader(countSlug);
|
|
674
|
+
if (!header) return null;
|
|
675
|
+
var r = await query(
|
|
676
|
+
"SELECT * FROM cycle_count_lines WHERE count_slug = ?1 AND " +
|
|
677
|
+
"variance IS NOT NULL AND variance != 0 ORDER BY sku ASC",
|
|
678
|
+
[countSlug],
|
|
679
|
+
);
|
|
680
|
+
var out = [];
|
|
681
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
682
|
+
var ln = r.rows[i];
|
|
683
|
+
out.push({
|
|
684
|
+
sku: ln.sku,
|
|
685
|
+
location_code: ln.location_code,
|
|
686
|
+
expected_quantity: ln.expected_quantity,
|
|
687
|
+
actual_quantity: ln.actual_quantity,
|
|
688
|
+
variance: ln.variance,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
return out;
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
// List count headers, optionally narrowed by kind + a
|
|
695
|
+
// scheduled_at date range. Ordered by scheduled_at DESC so the
|
|
696
|
+
// operator's dashboard shows the freshest counts first.
|
|
697
|
+
listCounts: async function (listOpts) {
|
|
698
|
+
listOpts = listOpts || {};
|
|
699
|
+
var wheres = [];
|
|
700
|
+
var params = [];
|
|
701
|
+
var p = 1;
|
|
702
|
+
if (listOpts.kind != null) {
|
|
703
|
+
_kind(listOpts.kind);
|
|
704
|
+
wheres.push("kind = ?" + p);
|
|
705
|
+
params.push(listOpts.kind);
|
|
706
|
+
p += 1;
|
|
707
|
+
}
|
|
708
|
+
if (listOpts.from != null) {
|
|
709
|
+
_epochMs(listOpts.from, "from");
|
|
710
|
+
wheres.push("scheduled_at >= ?" + p);
|
|
711
|
+
params.push(listOpts.from);
|
|
712
|
+
p += 1;
|
|
713
|
+
}
|
|
714
|
+
if (listOpts.to != null) {
|
|
715
|
+
_epochMs(listOpts.to, "to");
|
|
716
|
+
wheres.push("scheduled_at <= ?" + p);
|
|
717
|
+
params.push(listOpts.to);
|
|
718
|
+
p += 1;
|
|
719
|
+
}
|
|
720
|
+
var whereClause = wheres.length ? "WHERE " + wheres.join(" AND ") + " " : "";
|
|
721
|
+
var sql = "SELECT * FROM cycle_counts " + whereClause +
|
|
722
|
+
"ORDER BY scheduled_at DESC, slug DESC";
|
|
723
|
+
var r = await query(sql, params);
|
|
724
|
+
var rows = r.rows;
|
|
725
|
+
// Hydrate the scope payload for every header so admin
|
|
726
|
+
// dashboards don't re-parse the JSON column on the client.
|
|
727
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
728
|
+
rows[i].scope = JSON.parse(rows[i].scope_json);
|
|
729
|
+
}
|
|
730
|
+
return rows;
|
|
731
|
+
},
|
|
732
|
+
|
|
733
|
+
// Hydrated single read or null on miss.
|
|
734
|
+
getCount: async function (slug) {
|
|
735
|
+
_slug(slug);
|
|
736
|
+
return await _getHydrated(slug);
|
|
737
|
+
},
|
|
738
|
+
|
|
739
|
+
// Keyset-paginated history of every cycle-count line that
|
|
740
|
+
// touched a SKU. The operator dashboard's per-SKU drill-down
|
|
741
|
+
// reads this — "show me every count that recorded WDG-1, with
|
|
742
|
+
// the variance each time." Returns lines without filtering by
|
|
743
|
+
// status: an unfinalized count's line shows up with variance=null,
|
|
744
|
+
// which the dashboard renders as "in-progress count".
|
|
745
|
+
historyForSku: async function (sku, listOpts) {
|
|
746
|
+
_sku(sku);
|
|
747
|
+
listOpts = listOpts || {};
|
|
748
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
749
|
+
_limit(limit);
|
|
750
|
+
var cursorVals = null;
|
|
751
|
+
if (listOpts.cursor != null) {
|
|
752
|
+
if (typeof listOpts.cursor !== "string") {
|
|
753
|
+
throw new TypeError("cycle-counting.historyForSku: cursor must be an opaque string or null");
|
|
754
|
+
}
|
|
755
|
+
try {
|
|
756
|
+
var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
|
|
757
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(LINE_ORDER_KEY)) {
|
|
758
|
+
throw new TypeError("cycle-counting.historyForSku: cursor orderKey mismatch");
|
|
759
|
+
}
|
|
760
|
+
cursorVals = state.vals;
|
|
761
|
+
} catch (e) {
|
|
762
|
+
if (e instanceof TypeError) throw e;
|
|
763
|
+
throw new TypeError("cycle-counting.historyForSku: cursor — " + (e && e.message || "malformed"));
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
// Order by counted_at DESC NULLS LAST, id DESC. Lines that
|
|
767
|
+
// haven't been counted yet (counted_at IS NULL) sort to the
|
|
768
|
+
// back so the operator sees the most recent activity first.
|
|
769
|
+
// SQLite sorts NULLs first by default; the IFNULL flip pushes
|
|
770
|
+
// them to the back of a DESC sort.
|
|
771
|
+
var sql, params;
|
|
772
|
+
if (cursorVals) {
|
|
773
|
+
sql = "SELECT * FROM cycle_count_lines WHERE sku = ?1 AND " +
|
|
774
|
+
"(IFNULL(counted_at, 0) < ?2 OR (IFNULL(counted_at, 0) = ?2 AND id < ?3)) " +
|
|
775
|
+
"ORDER BY IFNULL(counted_at, 0) DESC, id DESC LIMIT ?4";
|
|
776
|
+
params = [sku, cursorVals[0], cursorVals[1], limit];
|
|
777
|
+
} else {
|
|
778
|
+
sql = "SELECT * FROM cycle_count_lines WHERE sku = ?1 " +
|
|
779
|
+
"ORDER BY IFNULL(counted_at, 0) DESC, id DESC LIMIT ?2";
|
|
780
|
+
params = [sku, limit];
|
|
781
|
+
}
|
|
782
|
+
var r = await query(sql, params);
|
|
783
|
+
var rows = r.rows;
|
|
784
|
+
var last = rows[rows.length - 1];
|
|
785
|
+
var next = null;
|
|
786
|
+
if (last && rows.length === limit) {
|
|
787
|
+
next = _b().pagination.encodeCursor({
|
|
788
|
+
orderKey: LINE_ORDER_KEY,
|
|
789
|
+
vals: [last.counted_at == null ? 0 : last.counted_at, last.id],
|
|
790
|
+
forward: true,
|
|
791
|
+
}, cursorSecret);
|
|
792
|
+
}
|
|
793
|
+
return { rows: rows, next_cursor: next };
|
|
794
|
+
},
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
module.exports = {
|
|
799
|
+
create: create,
|
|
800
|
+
KINDS: KINDS,
|
|
801
|
+
STATUSES: STATUSES,
|
|
802
|
+
};
|