@blamejs/blamejs-shop 0.0.72 → 0.0.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,852 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.inventoryAudits
4
+ * @title Inventory audits — periodic full-inventory snapshot +
5
+ * reconciliation (year-end / quarter-end / spot)
6
+ *
7
+ * @intro
8
+ * Cycle counting (`cycleCounting`) runs on a rolling cadence — pick
9
+ * a subset of SKUs, walk a few shelves, write the variance, repeat.
10
+ * That model closes the steady-state drift but it does not produce
11
+ * the comprehensive "every SKU at every covered location, captured
12
+ * in a single window" snapshot that quarter-end and year-end
13
+ * reconciliation demands. Insurers want the latter. Auditors want
14
+ * the latter. The general ledger's closing entries want the latter.
15
+ *
16
+ * This primitive owns that comprehensive audit: open one with
17
+ * `openAudit({ slug, kind, scope })`, record per-scan lines as the
18
+ * shelf is walked with `recordScanLine`, flag variance lines for a
19
+ * second pair of eyes with `markRecount`, then `finalizeAudit` to
20
+ * compute the per-line variance + roll-up totals onto the header.
21
+ * When `apply_adjustments` is true and an `inventoryLocations` dep
22
+ * is wired, finalize writes per-shelf adjustments through
23
+ * `inventoryLocations.adjustStock` so the audit trail on
24
+ * `inventory_adjustments` carries `reason = "inventory-audit:<slug>"`
25
+ * — operators reconstruct "which audit produced this write-off"
26
+ * from a single column.
27
+ *
28
+ * Distinct from `cycleCounting`:
29
+ * - cycleCounting picks subsets on a cadence (rotating / abc /
30
+ * full-active-skus) for steady-state shrinkage detection.
31
+ * - inventoryAudits captures a single comprehensive window: every
32
+ * SKU at every covered location, scoped by category / vendor /
33
+ * location / all. Year-end + quarterly reconciliation drive the
34
+ * cadence; spot audits handle "the auditor wants the AC-VENDOR
35
+ * inventory recounted by tomorrow."
36
+ *
37
+ * Lifecycle (four-state FSM):
38
+ *
39
+ * openAudit({ slug, kind, scope, scheduled_at, location_codes? })
40
+ * Persists the header at status='open'. `kind` is full /
41
+ * quarterly / spot; `scope` is all / category / vendor /
42
+ * location; `location_codes` is an optional array of
43
+ * location_code values when the audit narrows to specific
44
+ * warehouses (required when scope === 'location'; allowed for
45
+ * any kind to constrain the walk).
46
+ *
47
+ * recordScanLine({ audit_id, sku, location_code, counted_qty, counter_id })
48
+ * Records one scan. Captures `expected_qty` from
49
+ * `inventoryLocations.stockForSku(sku)` at scan time (per-
50
+ * location when location_code is set, otherwise sum across
51
+ * every location the snapshot covers). Re-scanning the same
52
+ * (sku, location_code) overwrites the prior counted_qty — the
53
+ * operator is correcting a typo, not appending a duplicate.
54
+ * Transitions open -> in_progress on first scan.
55
+ *
56
+ * markRecount({ audit_id, sku, location_code, recount_qty, recount_by })
57
+ * For lines where the original count produced a variance worth
58
+ * a second-pair-of-eyes recount. Patches `recount_qty` +
59
+ * `recounted_by` + `recounted_at` on the scan row. The recount
60
+ * value (when present) wins over `counted_qty` at finalize time.
61
+ *
62
+ * finalizeAudit({ audit_id, apply_adjustments? })
63
+ * in_progress -> finalized. Walks every scan line, computes
64
+ * `variance = effective_qty - expected_qty` where `effective_qty`
65
+ * is `recount_qty` when present, else `counted_qty`. Writes
66
+ * `variance` on every line. Aggregates `variance_count` (non-
67
+ * zero lines) and `variance_value_minor` (sum of |variance| *
68
+ * unit_value via the optional `costLayers` dep) onto the header.
69
+ * When apply_adjustments=true and inventoryLocations is wired,
70
+ * calls adjustStock for every non-zero variance with reason
71
+ * "inventory-audit:<slug>". Returns `{ variance_count,
72
+ * variance_value_minor, adjustments_written }`.
73
+ *
74
+ * cancelAudit({ audit_id, reason })
75
+ * open|in_progress -> cancelled. Persists the reason +
76
+ * cancelled_at timestamp. Cancelling a finalized audit is
77
+ * refused — adjustments have already landed.
78
+ *
79
+ * Reads:
80
+ * getAudit(audit_id) — hydrated header + lines
81
+ * listAudits({ kind?, status?, year? }) — operator dashboard listing
82
+ * variancesForAudit(audit_id) — non-zero variance lines
83
+ * historyForSku(sku) — every audit touching a SKU
84
+ * compareToPriorAudit({ audit_id }) — delta vs. the previous
85
+ * finalized audit of the
86
+ * same kind
87
+ *
88
+ * Composition:
89
+ * - b.uuid.v7 — audit + line row PKs (sortable)
90
+ * - b.guardUuid — strict UUID validation on audit_id reads
91
+ * - inventoryLocations — required at openAudit time so the per-
92
+ * shelf expected_qty is captured at scan
93
+ * time; required when finalizeAudit({
94
+ * apply_adjustments: true }) is called so
95
+ * the variance writes through adjustStock.
96
+ * The factory accepts the handle as
97
+ * optional so isolated tests can stub the
98
+ * shape; runtime calls that need it but
99
+ * find it missing throw at the call site.
100
+ * - inventorySnapshots (optional) — when wired, openAudit can pin
101
+ * a snapshot label tying the audit to a
102
+ * point-in-time capture. Not required —
103
+ * the audit captures expected_qty at
104
+ * scan time directly.
105
+ * - costLayers (optional) — when wired, finalizeAudit asks for the
106
+ * per-SKU unit cost so variance_value_minor
107
+ * rolls up in minor currency units.
108
+ * Without it, every line contributes 0 to
109
+ * the value roll-up; the per-line variance
110
+ * ints still carry the operator signal.
111
+ *
112
+ * Three-tier input validation: every public verb here is either a
113
+ * config-time entry point (openAudit, cancelAudit) or a defensive
114
+ * request-shape reader (recordScanLine, markRecount, finalizeAudit,
115
+ * variancesForAudit, listAudits, historyForSku, compareToPriorAudit).
116
+ * Both shapes throw on bad input — no drop-silent hot-path sinks.
117
+ *
118
+ * @primitive inventoryAudits
119
+ * @related cycleCounting, inventorySnapshots, inventoryLocations,
120
+ * costLayers
121
+ */
122
+
123
+ var bShop;
124
+ function _b() {
125
+ if (!bShop) bShop = require("./index");
126
+ return bShop.framework;
127
+ }
128
+
129
+ // ---- constants ----------------------------------------------------------
130
+
131
+ var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,79}$/;
132
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
133
+ var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
134
+ var COUNTER_RE = /^[\S\s]{1,128}$/;
135
+ var KINDS = Object.freeze(["full", "quarterly", "spot"]);
136
+ var SCOPES = Object.freeze(["all", "category", "vendor", "location"]);
137
+ var STATUSES = Object.freeze(["open", "in_progress", "finalized", "cancelled"]);
138
+ var MAX_REASON = 280;
139
+ var MAX_LOCATIONS = 256;
140
+ var MAX_LIST_LIMIT = 200;
141
+
142
+ // ---- monotonic clock ----------------------------------------------------
143
+ //
144
+ // FSM transitions, scan recordings, and recount stamps all land on
145
+ // epoch-ms timestamps. Two scans against the same audit can arrive in
146
+ // the same millisecond from concurrent handheld scanners; strict-
147
+ // monotonic ordering guarantees `counted_at` is distinct row-by-row so
148
+ // a chronological dashboard sort returns events in the order they
149
+ // were issued.
150
+ var _lastTs = 0;
151
+ function _now() {
152
+ var t = Date.now();
153
+ if (t <= _lastTs) { t = _lastTs + 1; }
154
+ _lastTs = t;
155
+ return t;
156
+ }
157
+
158
+ // ---- validators ---------------------------------------------------------
159
+
160
+ function _slug(s) {
161
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
162
+ throw new TypeError("inventory-audits: slug must match /^[a-z0-9][a-z0-9-]*$/ (lowercase alnum + dash, 1..80 chars)");
163
+ }
164
+ }
165
+ function _sku(s) {
166
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
167
+ throw new TypeError("inventory-audits: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
168
+ }
169
+ }
170
+ function _code(s, label) {
171
+ if (typeof s !== "string" || !CODE_RE.test(s)) {
172
+ throw new TypeError("inventory-audits: " + (label || "location_code") +
173
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
174
+ }
175
+ }
176
+ function _codeOrNull(s, label) {
177
+ if (s == null) return null;
178
+ _code(s, label);
179
+ return s;
180
+ }
181
+ function _kind(s) {
182
+ if (KINDS.indexOf(s) === -1) {
183
+ throw new TypeError("inventory-audits: kind must be one of " + KINDS.join(", ") +
184
+ ", got " + JSON.stringify(s));
185
+ }
186
+ }
187
+ function _scope(s) {
188
+ if (SCOPES.indexOf(s) === -1) {
189
+ throw new TypeError("inventory-audits: scope must be one of " + SCOPES.join(", ") +
190
+ ", got " + JSON.stringify(s));
191
+ }
192
+ }
193
+ function _status(s) {
194
+ if (STATUSES.indexOf(s) === -1) {
195
+ throw new TypeError("inventory-audits: status must be one of " + STATUSES.join(", ") +
196
+ ", got " + JSON.stringify(s));
197
+ }
198
+ }
199
+ function _auditId(s) {
200
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
201
+ catch (e) { throw new TypeError("inventory-audits: audit_id — " + (e && e.message || "invalid UUID")); }
202
+ }
203
+ function _epochMs(n, label) {
204
+ if (!Number.isInteger(n) || n < 0) {
205
+ throw new TypeError("inventory-audits: " + label + " must be a non-negative integer (epoch ms)");
206
+ }
207
+ }
208
+ function _nonNegInt(n, label) {
209
+ if (!Number.isInteger(n) || n < 0) {
210
+ throw new TypeError("inventory-audits: " + label + " must be a non-negative integer");
211
+ }
212
+ }
213
+ function _counter(s, label) {
214
+ if (s == null) return null;
215
+ if (typeof s !== "string" || !COUNTER_RE.test(s) || s.length > 128) {
216
+ throw new TypeError("inventory-audits: " + (label || "counter_id") + " must be a string ≤ 128 chars");
217
+ }
218
+ return s;
219
+ }
220
+ function _counterRequired(s, label) {
221
+ if (typeof s !== "string" || !COUNTER_RE.test(s) || s.length > 128) {
222
+ throw new TypeError("inventory-audits: " + (label || "counter_id") + " must be a non-empty string ≤ 128 chars");
223
+ }
224
+ return s;
225
+ }
226
+ function _reason(s) {
227
+ if (s == null) return "";
228
+ if (typeof s !== "string" || s.length > MAX_REASON) {
229
+ throw new TypeError("inventory-audits: reason must be a string ≤ " + MAX_REASON + " chars");
230
+ }
231
+ return s;
232
+ }
233
+ function _year(n) {
234
+ if (!Number.isInteger(n) || n < 1970 || n > 9999) {
235
+ throw new TypeError("inventory-audits: year must be a four-digit integer in 1970..9999");
236
+ }
237
+ }
238
+ function _locationCodes(arr) {
239
+ if (arr == null) return null;
240
+ if (!Array.isArray(arr)) {
241
+ throw new TypeError("inventory-audits: location_codes must be an array of codes");
242
+ }
243
+ if (arr.length === 0) {
244
+ throw new TypeError("inventory-audits: location_codes, when supplied, must be a non-empty array");
245
+ }
246
+ if (arr.length > MAX_LOCATIONS) {
247
+ throw new TypeError("inventory-audits: location_codes must contain ≤ " + MAX_LOCATIONS + " entries");
248
+ }
249
+ var seen = Object.create(null);
250
+ var out = [];
251
+ for (var i = 0; i < arr.length; i += 1) {
252
+ _code(arr[i], "location_codes[" + i + "]");
253
+ if (seen[arr[i]]) {
254
+ throw new TypeError("inventory-audits: duplicate location_code " + JSON.stringify(arr[i]) +
255
+ " in location_codes");
256
+ }
257
+ seen[arr[i]] = true;
258
+ out.push(arr[i]);
259
+ }
260
+ return out;
261
+ }
262
+
263
+ // Year start/end epoch-ms (UTC). The dashboard's "show me Q4 2025 +
264
+ // the year-end full audit" query lands here — every audit with
265
+ // scheduled_at in [year-start, year-end] returns.
266
+ function _yearRange(year) {
267
+ var fromIso = Date.UTC(year, 0, 1, 0, 0, 0, 0);
268
+ var toIso = Date.UTC(year + 1, 0, 1, 0, 0, 0, 0) - 1;
269
+ return { from: fromIso, to: toIso };
270
+ }
271
+
272
+ // ---- factory ------------------------------------------------------------
273
+
274
+ function create(opts) {
275
+ opts = opts || {};
276
+ // inventoryLocations is optional at boot — runtime calls that need
277
+ // it (recordScanLine for expected_qty capture, finalizeAudit with
278
+ // apply_adjustments) throw at the call site if it's missing. The
279
+ // factory still shape-checks when the handle is supplied so a typo
280
+ // surfaces at boot rather than at first scan.
281
+ var inventoryLocations = opts.inventoryLocations || null;
282
+ if (inventoryLocations !== null) {
283
+ if (typeof inventoryLocations !== "object"
284
+ || typeof inventoryLocations.stockForSku !== "function") {
285
+ throw new TypeError("inventory-audits.create: opts.inventoryLocations, when supplied, " +
286
+ "must expose stockForSku(sku)");
287
+ }
288
+ }
289
+ // inventorySnapshots is optional — the operator wires it when they
290
+ // want the audit to pin a point-in-time snapshot label. Not used
291
+ // for the variance math itself; reserved for downstream reporting.
292
+ var inventorySnapshots = opts.inventorySnapshots || null;
293
+ if (inventorySnapshots !== null && typeof inventorySnapshots !== "object") {
294
+ throw new TypeError("inventory-audits.create: opts.inventorySnapshots, when supplied, must be an object");
295
+ }
296
+ // costLayers is optional — when wired, finalizeAudit asks for the
297
+ // per-SKU unit cost so variance_value_minor rolls up in minor
298
+ // currency units. Without it, every line contributes 0 to the value
299
+ // roll-up; the per-line variance ints still carry the operator
300
+ // signal.
301
+ var costLayers = opts.costLayers || null;
302
+ if (costLayers !== null && typeof costLayers !== "object") {
303
+ throw new TypeError("inventory-audits.create: opts.costLayers, when supplied, must be an object");
304
+ }
305
+ var query = opts.query;
306
+ if (!query) {
307
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
308
+ }
309
+
310
+ // Read the audit header by id or null on miss.
311
+ async function _getHeader(auditId) {
312
+ var r = await query("SELECT * FROM inventory_audits WHERE id = ?1", [auditId]);
313
+ return r.rows[0] || null;
314
+ }
315
+
316
+ // Read the audit header by slug or null on miss.
317
+ async function _getHeaderBySlug(slug) {
318
+ var r = await query("SELECT * FROM inventory_audits WHERE slug = ?1", [slug]);
319
+ return r.rows[0] || null;
320
+ }
321
+
322
+ // Read every line for an audit, ordered by SKU then location_code so
323
+ // the operator-facing worksheet walks the shelf in a stable order.
324
+ async function _getLines(auditId) {
325
+ var r = await query(
326
+ "SELECT * FROM inventory_audit_lines WHERE audit_id = ?1 " +
327
+ "ORDER BY sku ASC, IFNULL(location_code, '') ASC",
328
+ [auditId],
329
+ );
330
+ return r.rows;
331
+ }
332
+
333
+ // Hydrate a header + its lines + its parsed location_codes. Returns
334
+ // null on miss so callers map cleanly to HTTP 404.
335
+ async function _getHydrated(auditId) {
336
+ var header = await _getHeader(auditId);
337
+ if (!header) return null;
338
+ header.location_codes = header.location_codes_json
339
+ ? JSON.parse(header.location_codes_json)
340
+ : null;
341
+ header.lines = await _getLines(auditId);
342
+ return header;
343
+ }
344
+
345
+ // Pull the expected_qty for (sku, location_code). Per-location reads
346
+ // hit the matching by_location entry; whole-audit reads sum across
347
+ // the audit's location_codes (or every location, when location_codes
348
+ // is null).
349
+ async function _expectedQty(sku, locationCode, auditLocationCodes) {
350
+ if (!inventoryLocations) {
351
+ throw new TypeError("inventory-audits: opts.inventoryLocations is required to capture " +
352
+ "expected_qty at scan time");
353
+ }
354
+ var stock = await inventoryLocations.stockForSku(sku);
355
+ var locs = (stock && stock.by_location) || [];
356
+ if (locationCode != null) {
357
+ for (var i = 0; i < locs.length; i += 1) {
358
+ if (locs[i].code === locationCode) return Number(locs[i].quantity) || 0;
359
+ }
360
+ return 0;
361
+ }
362
+ // No location_code on the scan line → sum across the audit's
363
+ // location set (or every location, when the audit has no
364
+ // location_codes constraint).
365
+ var total = 0;
366
+ for (var j = 0; j < locs.length; j += 1) {
367
+ if (auditLocationCodes && auditLocationCodes.indexOf(locs[j].code) === -1) continue;
368
+ total += Number(locs[j].quantity) || 0;
369
+ }
370
+ return total;
371
+ }
372
+
373
+ // Optional unit-cost read for the variance_value_minor roll-up.
374
+ // costLayers adapters that don't expose unitCostMinor get a 0
375
+ // fallback — the variance count + per-line variance ints still
376
+ // carry the operator signal; only the monetary roll-up collapses.
377
+ async function _unitValueMinor(sku) {
378
+ if (!costLayers || typeof costLayers.unitCostMinor !== "function") return 0;
379
+ var v = await costLayers.unitCostMinor(sku);
380
+ if (v == null) return 0;
381
+ if (!Number.isInteger(v) || v < 0) {
382
+ throw new TypeError("inventory-audits: costLayers.unitCostMinor(" + JSON.stringify(sku) +
383
+ ") must return a non-negative integer or null, got " + JSON.stringify(v));
384
+ }
385
+ return v;
386
+ }
387
+
388
+ return {
389
+
390
+ // Exposed for tests + admin dashboards.
391
+ KINDS: KINDS,
392
+ SCOPES: SCOPES,
393
+ STATUSES: STATUSES,
394
+
395
+ // Open a new audit. The header lands at status='open' with no
396
+ // scan lines; the operator drives recordScanLine for each (sku,
397
+ // location_code) pair as the shelf is walked.
398
+ openAudit: async function (input) {
399
+ if (!input || typeof input !== "object") {
400
+ throw new TypeError("inventory-audits.openAudit: input object required");
401
+ }
402
+ _slug(input.slug);
403
+ _kind(input.kind);
404
+ _scope(input.scope);
405
+ _epochMs(input.scheduled_at, "scheduled_at");
406
+ var locationCodes = _locationCodes(input.location_codes);
407
+ // scope === 'location' demands location_codes — the operator is
408
+ // explicitly narrowing the audit to specific warehouses; an
409
+ // empty narrow is a typo we surface up front rather than
410
+ // silently expanding to every location.
411
+ if (input.scope === "location" && (!locationCodes || locationCodes.length === 0)) {
412
+ throw new TypeError("inventory-audits.openAudit: scope=location requires location_codes " +
413
+ "(a non-empty array of location codes)");
414
+ }
415
+ // Refuse redefine. Operators that hit this should cancel the
416
+ // prior audit (cancelAudit) or pick a new slug — silent
417
+ // overwrite would clobber the variance numbers on a finalized
418
+ // audit and is never what the operator wants.
419
+ var existing = await _getHeaderBySlug(input.slug);
420
+ if (existing) {
421
+ throw new TypeError("inventory-audits.openAudit: slug " +
422
+ JSON.stringify(input.slug) + " already exists (status " + existing.status +
423
+ ") — cancel or pick a new slug");
424
+ }
425
+ var id = _b().uuid.v7();
426
+ var ts = _now();
427
+ await query(
428
+ "INSERT INTO inventory_audits (id, slug, kind, scope, scheduled_at, status, " +
429
+ "variance_count, variance_value_minor, finalized_at, cancelled_at, cancel_reason, " +
430
+ "location_codes_json, created_at, updated_at) " +
431
+ "VALUES (?1, ?2, ?3, ?4, ?5, 'open', NULL, NULL, NULL, NULL, NULL, ?6, ?7, ?7)",
432
+ [
433
+ id,
434
+ input.slug,
435
+ input.kind,
436
+ input.scope,
437
+ input.scheduled_at,
438
+ locationCodes ? JSON.stringify(locationCodes) : null,
439
+ ts,
440
+ ],
441
+ );
442
+ return await _getHydrated(id);
443
+ },
444
+
445
+ // Record one scan. Captures expected_qty at scan time from the
446
+ // wired inventoryLocations. Re-scanning the same (sku,
447
+ // location_code) overwrites the prior counted_qty — the operator
448
+ // is correcting a typo, not appending a duplicate. Transitions
449
+ // open -> in_progress on first scan.
450
+ recordScanLine: async function (input) {
451
+ if (!input || typeof input !== "object") {
452
+ throw new TypeError("inventory-audits.recordScanLine: input object required");
453
+ }
454
+ var auditId = _auditId(input.audit_id);
455
+ _sku(input.sku);
456
+ var locationCode = _codeOrNull(input.location_code, "location_code");
457
+ _nonNegInt(input.counted_qty, "counted_qty");
458
+ var counterId = _counterRequired(input.counter_id, "counter_id");
459
+
460
+ var header = await _getHeader(auditId);
461
+ if (!header) {
462
+ throw new TypeError("inventory-audits.recordScanLine: audit " +
463
+ JSON.stringify(auditId) + " not found");
464
+ }
465
+ if (header.status !== "open" && header.status !== "in_progress") {
466
+ throw new TypeError("inventory-audits.recordScanLine: audit is " + header.status +
467
+ ", only open or in_progress audits accept scans");
468
+ }
469
+ var auditLocationCodes = header.location_codes_json
470
+ ? JSON.parse(header.location_codes_json)
471
+ : null;
472
+ // When the audit narrows to a location set, a scan with a
473
+ // location_code outside that set is a typo we surface up front.
474
+ if (locationCode != null && auditLocationCodes
475
+ && auditLocationCodes.indexOf(locationCode) === -1) {
476
+ throw new TypeError("inventory-audits.recordScanLine: location_code " +
477
+ JSON.stringify(locationCode) + " is not in this audit's location_codes " +
478
+ JSON.stringify(auditLocationCodes));
479
+ }
480
+ var expectedQty = await _expectedQty(input.sku, locationCode, auditLocationCodes);
481
+ var ts = _now();
482
+
483
+ // Re-scan overwrites the prior row. The UNIQUE index on
484
+ // (audit_id, sku, IFNULL(location_code, '')) enforces single-
485
+ // row-per-key; we look up the existing row first so the audit
486
+ // trail's counted_at reflects the latest scan.
487
+ var existingRows = await query(
488
+ "SELECT id FROM inventory_audit_lines WHERE audit_id = ?1 AND sku = ?2 AND " +
489
+ "IFNULL(location_code, '') = ?3",
490
+ [auditId, input.sku, locationCode || ""],
491
+ );
492
+ if (existingRows.rows.length > 0) {
493
+ await query(
494
+ "UPDATE inventory_audit_lines SET counted_qty = ?1, counted_by = ?2, " +
495
+ "counted_at = ?3, expected_qty = ?4 WHERE id = ?5",
496
+ [input.counted_qty, counterId, ts, expectedQty, existingRows.rows[0].id],
497
+ );
498
+ } else {
499
+ await query(
500
+ "INSERT INTO inventory_audit_lines (id, audit_id, sku, location_code, " +
501
+ "expected_qty, counted_qty, recount_qty, recounted_by, recounted_at, " +
502
+ "variance, counted_by, counted_at) " +
503
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL, NULL, NULL, ?7, ?8)",
504
+ [_b().uuid.v7(), auditId, input.sku, locationCode, expectedQty, input.counted_qty, counterId, ts],
505
+ );
506
+ }
507
+ if (header.status === "open") {
508
+ await query(
509
+ "UPDATE inventory_audits SET status = 'in_progress', updated_at = ?1 WHERE id = ?2",
510
+ [ts, auditId],
511
+ );
512
+ } else {
513
+ await query(
514
+ "UPDATE inventory_audits SET updated_at = ?1 WHERE id = ?2",
515
+ [ts, auditId],
516
+ );
517
+ }
518
+ return await _getHydrated(auditId);
519
+ },
520
+
521
+ // Patch a recount on an existing scan row. The recount value (when
522
+ // present) wins over `counted_qty` at finalize time. Refuses lines
523
+ // that don't already exist — the operator must recordScanLine
524
+ // first; markRecount only overrides an existing scan.
525
+ markRecount: async function (input) {
526
+ if (!input || typeof input !== "object") {
527
+ throw new TypeError("inventory-audits.markRecount: input object required");
528
+ }
529
+ var auditId = _auditId(input.audit_id);
530
+ _sku(input.sku);
531
+ var locationCode = _codeOrNull(input.location_code, "location_code");
532
+ _nonNegInt(input.recount_qty, "recount_qty");
533
+ var recountBy = _counterRequired(input.recount_by, "recount_by");
534
+
535
+ var header = await _getHeader(auditId);
536
+ if (!header) {
537
+ throw new TypeError("inventory-audits.markRecount: audit " +
538
+ JSON.stringify(auditId) + " not found");
539
+ }
540
+ if (header.status !== "open" && header.status !== "in_progress") {
541
+ throw new TypeError("inventory-audits.markRecount: audit is " + header.status +
542
+ ", only open or in_progress audits accept recounts");
543
+ }
544
+ var existingRows = await query(
545
+ "SELECT id FROM inventory_audit_lines WHERE audit_id = ?1 AND sku = ?2 AND " +
546
+ "IFNULL(location_code, '') = ?3",
547
+ [auditId, input.sku, locationCode || ""],
548
+ );
549
+ if (existingRows.rows.length === 0) {
550
+ throw new TypeError("inventory-audits.markRecount: no scan line for sku " +
551
+ JSON.stringify(input.sku) +
552
+ (locationCode ? " at location " + JSON.stringify(locationCode) : "") +
553
+ " on audit " + JSON.stringify(auditId) + " — call recordScanLine first");
554
+ }
555
+ var ts = _now();
556
+ await query(
557
+ "UPDATE inventory_audit_lines SET recount_qty = ?1, recounted_by = ?2, " +
558
+ "recounted_at = ?3 WHERE id = ?4",
559
+ [input.recount_qty, recountBy, ts, existingRows.rows[0].id],
560
+ );
561
+ await query(
562
+ "UPDATE inventory_audits SET updated_at = ?1 WHERE id = ?2",
563
+ [ts, auditId],
564
+ );
565
+ return await _getHydrated(auditId);
566
+ },
567
+
568
+ // Compute per-line variance, aggregate to the header, optionally
569
+ // write per-shelf adjustments via inventoryLocations.adjustStock.
570
+ // Returns the aggregated numbers + adjustments_written count so
571
+ // the caller doesn't re-read the header.
572
+ finalizeAudit: async function (input) {
573
+ if (!input || typeof input !== "object") {
574
+ throw new TypeError("inventory-audits.finalizeAudit: input object required");
575
+ }
576
+ var auditId = _auditId(input.audit_id);
577
+ var applyAdjustments = false;
578
+ if (input.apply_adjustments != null) {
579
+ if (typeof input.apply_adjustments !== "boolean") {
580
+ throw new TypeError("inventory-audits.finalizeAudit: apply_adjustments must be a boolean");
581
+ }
582
+ applyAdjustments = input.apply_adjustments;
583
+ }
584
+ if (applyAdjustments && !inventoryLocations) {
585
+ throw new TypeError("inventory-audits.finalizeAudit: apply_adjustments=true requires " +
586
+ "opts.inventoryLocations to be wired into the factory");
587
+ }
588
+ if (applyAdjustments && typeof inventoryLocations.adjustStock !== "function") {
589
+ throw new TypeError("inventory-audits.finalizeAudit: apply_adjustments=true requires " +
590
+ "opts.inventoryLocations.adjustStock(input)");
591
+ }
592
+ var header = await _getHeader(auditId);
593
+ if (!header) {
594
+ throw new TypeError("inventory-audits.finalizeAudit: audit " +
595
+ JSON.stringify(auditId) + " not found");
596
+ }
597
+ if (header.status !== "open" && header.status !== "in_progress") {
598
+ throw new TypeError("inventory-audits.finalizeAudit: audit is " + header.status +
599
+ ", only open or in_progress audits can be finalized");
600
+ }
601
+ var lines = await _getLines(auditId);
602
+ var varianceCount = 0;
603
+ var varianceValueMinor = 0;
604
+ var adjustmentsWritten = 0;
605
+ for (var i = 0; i < lines.length; i += 1) {
606
+ var line = lines[i];
607
+ // recount_qty wins when present; else counted_qty drives.
608
+ var effective = line.recount_qty != null ? line.recount_qty : line.counted_qty;
609
+ var variance = effective - line.expected_qty;
610
+ await query(
611
+ "UPDATE inventory_audit_lines SET variance = ?1 WHERE id = ?2",
612
+ [variance, line.id],
613
+ );
614
+ if (variance !== 0) {
615
+ varianceCount += 1;
616
+ var unitValue = await _unitValueMinor(line.sku);
617
+ varianceValueMinor += Math.abs(variance) * unitValue;
618
+ if (applyAdjustments) {
619
+ // Adjustments only land on lines that carry a
620
+ // location_code. A whole-audit-summed line (no
621
+ // location_code) computes variance for the operator's
622
+ // reconciliation report but does NOT write to
623
+ // inventory_locations — the catalog-side correction is
624
+ // the right verb for the single-bucket inventory.
625
+ if (line.location_code) {
626
+ await inventoryLocations.adjustStock({
627
+ sku: line.sku,
628
+ location_code: line.location_code,
629
+ delta: variance,
630
+ reason: "inventory-audit:" + header.slug,
631
+ });
632
+ adjustmentsWritten += 1;
633
+ }
634
+ }
635
+ }
636
+ }
637
+ var ts = _now();
638
+ await query(
639
+ "UPDATE inventory_audits SET status = 'finalized', variance_count = ?1, " +
640
+ "variance_value_minor = ?2, finalized_at = ?3, updated_at = ?3 WHERE id = ?4",
641
+ [varianceCount, varianceValueMinor, ts, auditId],
642
+ );
643
+ return {
644
+ variance_count: varianceCount,
645
+ variance_value_minor: varianceValueMinor,
646
+ adjustments_written: adjustmentsWritten,
647
+ };
648
+ },
649
+
650
+ // Abandon a non-terminal audit. The header survives so any
651
+ // dashboard read that captured the id still resolves; the line
652
+ // rows survive too (the FK is CASCADE on DELETE, not on
653
+ // cancellation) so operators can read the partial recordings as
654
+ // forensic data.
655
+ cancelAudit: async function (input) {
656
+ if (!input || typeof input !== "object") {
657
+ throw new TypeError("inventory-audits.cancelAudit: input object required");
658
+ }
659
+ var auditId = _auditId(input.audit_id);
660
+ var reason = _reason(input.reason);
661
+ if (!reason.length) {
662
+ throw new TypeError("inventory-audits.cancelAudit: reason must be a non-empty string");
663
+ }
664
+ var header = await _getHeader(auditId);
665
+ if (!header) {
666
+ throw new TypeError("inventory-audits.cancelAudit: audit " +
667
+ JSON.stringify(auditId) + " not found");
668
+ }
669
+ if (header.status === "finalized" || header.status === "cancelled") {
670
+ throw new TypeError("inventory-audits.cancelAudit: audit is " + header.status +
671
+ ", terminal states cannot be cancelled");
672
+ }
673
+ var ts = _now();
674
+ await query(
675
+ "UPDATE inventory_audits SET status = 'cancelled', cancelled_at = ?1, " +
676
+ "cancel_reason = ?2, updated_at = ?1 WHERE id = ?3",
677
+ [ts, reason, auditId],
678
+ );
679
+ return await _getHydrated(auditId);
680
+ },
681
+
682
+ // Hydrated single read or null on miss.
683
+ getAudit: async function (auditId) {
684
+ var id = _auditId(auditId);
685
+ return await _getHydrated(id);
686
+ },
687
+
688
+ // List audit headers, optionally narrowed by kind / status / year.
689
+ // year filters by scheduled_at within the UTC year boundaries.
690
+ // Ordered by scheduled_at DESC so the operator's dashboard shows
691
+ // the freshest audits first.
692
+ listAudits: async function (listOpts) {
693
+ listOpts = listOpts || {};
694
+ var wheres = [];
695
+ var params = [];
696
+ var p = 1;
697
+ if (listOpts.kind != null) {
698
+ _kind(listOpts.kind);
699
+ wheres.push("kind = ?" + p);
700
+ params.push(listOpts.kind);
701
+ p += 1;
702
+ }
703
+ if (listOpts.status != null) {
704
+ _status(listOpts.status);
705
+ wheres.push("status = ?" + p);
706
+ params.push(listOpts.status);
707
+ p += 1;
708
+ }
709
+ if (listOpts.year != null) {
710
+ _year(listOpts.year);
711
+ var range = _yearRange(listOpts.year);
712
+ wheres.push("scheduled_at >= ?" + p);
713
+ params.push(range.from);
714
+ p += 1;
715
+ wheres.push("scheduled_at <= ?" + p);
716
+ params.push(range.to);
717
+ p += 1;
718
+ }
719
+ var whereClause = wheres.length ? "WHERE " + wheres.join(" AND ") + " " : "";
720
+ var sql = "SELECT * FROM inventory_audits " + whereClause +
721
+ "ORDER BY scheduled_at DESC, slug DESC LIMIT " + MAX_LIST_LIMIT;
722
+ var r = await query(sql, params);
723
+ var rows = r.rows;
724
+ for (var i = 0; i < rows.length; i += 1) {
725
+ rows[i].location_codes = rows[i].location_codes_json
726
+ ? JSON.parse(rows[i].location_codes_json)
727
+ : null;
728
+ }
729
+ return rows;
730
+ },
731
+
732
+ // Per-line variance report. Returns every line with non-zero
733
+ // variance — the operator's reconciliation worksheet. Lines with
734
+ // NULL variance (audit not yet finalized) are filtered out.
735
+ variancesForAudit: async function (auditId) {
736
+ var id = _auditId(auditId);
737
+ var header = await _getHeader(id);
738
+ if (!header) return null;
739
+ var r = await query(
740
+ "SELECT * FROM inventory_audit_lines WHERE audit_id = ?1 AND " +
741
+ "variance IS NOT NULL AND variance != 0 ORDER BY sku ASC, IFNULL(location_code, '') ASC",
742
+ [id],
743
+ );
744
+ var out = [];
745
+ for (var i = 0; i < r.rows.length; i += 1) {
746
+ var ln = r.rows[i];
747
+ out.push({
748
+ sku: ln.sku,
749
+ location_code: ln.location_code,
750
+ expected_qty: ln.expected_qty,
751
+ counted_qty: ln.counted_qty,
752
+ recount_qty: ln.recount_qty,
753
+ variance: ln.variance,
754
+ });
755
+ }
756
+ return out;
757
+ },
758
+
759
+ // Every audit line that touched the SKU, ordered by counted_at
760
+ // DESC so the operator dashboard's per-SKU drill-down shows the
761
+ // most recent audit activity first.
762
+ historyForSku: async function (sku) {
763
+ _sku(sku);
764
+ var r = await query(
765
+ "SELECT l.*, a.slug AS audit_slug, a.kind AS audit_kind, a.status AS audit_status, " +
766
+ "a.scheduled_at AS audit_scheduled_at, a.finalized_at AS audit_finalized_at " +
767
+ "FROM inventory_audit_lines l JOIN inventory_audits a ON a.id = l.audit_id " +
768
+ "WHERE l.sku = ?1 ORDER BY l.counted_at DESC, l.id DESC LIMIT " + MAX_LIST_LIMIT,
769
+ [sku],
770
+ );
771
+ return r.rows;
772
+ },
773
+
774
+ // Compare a finalized audit to the prior finalized audit of the
775
+ // same kind. Returns per-SKU/location deltas (variance trend) so
776
+ // the operator dashboard surfaces "WDG-1 was short 3 in Q3 + short
777
+ // 5 in Q4 — a worsening trend." Returns null when the audit isn't
778
+ // finalized or no prior finalized audit of the same kind exists.
779
+ compareToPriorAudit: async function (input) {
780
+ if (!input || typeof input !== "object") {
781
+ throw new TypeError("inventory-audits.compareToPriorAudit: input object required");
782
+ }
783
+ var auditId = _auditId(input.audit_id);
784
+ var header = await _getHeader(auditId);
785
+ if (!header) {
786
+ throw new TypeError("inventory-audits.compareToPriorAudit: audit " +
787
+ JSON.stringify(auditId) + " not found");
788
+ }
789
+ if (header.status !== "finalized") {
790
+ throw new TypeError("inventory-audits.compareToPriorAudit: audit is " + header.status +
791
+ ", compareToPriorAudit only works on finalized audits");
792
+ }
793
+ // Pick the previous finalized audit of the same kind by
794
+ // scheduled_at. Ties broken by created_at DESC so a same-day
795
+ // re-audit picks the older one as "prior."
796
+ var priorRes = await query(
797
+ "SELECT * FROM inventory_audits WHERE kind = ?1 AND status = 'finalized' " +
798
+ "AND id != ?2 AND (scheduled_at < ?3 OR (scheduled_at = ?3 AND created_at < ?4)) " +
799
+ "ORDER BY scheduled_at DESC, created_at DESC LIMIT 1",
800
+ [header.kind, auditId, header.scheduled_at, header.created_at],
801
+ );
802
+ var prior = priorRes.rows[0];
803
+ if (!prior) {
804
+ return {
805
+ current_audit_id: auditId,
806
+ prior_audit_id: null,
807
+ deltas: [],
808
+ };
809
+ }
810
+ var currentLines = await _getLines(auditId);
811
+ var priorLines = await _getLines(prior.id);
812
+ var byKey = Object.create(null);
813
+ function _key(l) { return l.sku + "\x00" + (l.location_code || ""); }
814
+ for (var i = 0; i < priorLines.length; i += 1) {
815
+ byKey[_key(priorLines[i])] = { prior: priorLines[i], current: null };
816
+ }
817
+ for (var j = 0; j < currentLines.length; j += 1) {
818
+ var k = _key(currentLines[j]);
819
+ if (byKey[k]) byKey[k].current = currentLines[j];
820
+ else byKey[k] = { prior: null, current: currentLines[j] };
821
+ }
822
+ var deltas = [];
823
+ var keys = Object.keys(byKey).sort();
824
+ for (var x = 0; x < keys.length; x += 1) {
825
+ var pair = byKey[keys[x]];
826
+ var priorVar = pair.prior && pair.prior.variance != null ? pair.prior.variance : 0;
827
+ var curVar = pair.current && pair.current.variance != null ? pair.current.variance : 0;
828
+ var ref = pair.current || pair.prior;
829
+ if (priorVar === 0 && curVar === 0) continue;
830
+ deltas.push({
831
+ sku: ref.sku,
832
+ location_code: ref.location_code,
833
+ prior_variance: priorVar,
834
+ current_variance: curVar,
835
+ delta: curVar - priorVar,
836
+ });
837
+ }
838
+ return {
839
+ current_audit_id: auditId,
840
+ prior_audit_id: prior.id,
841
+ deltas: deltas,
842
+ };
843
+ },
844
+ };
845
+ }
846
+
847
+ module.exports = {
848
+ create: create,
849
+ KINDS: KINDS,
850
+ SCOPES: SCOPES,
851
+ STATUSES: STATUSES,
852
+ };