@blamejs/blamejs-shop 0.0.64 → 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.
@@ -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
+ };