@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.
- package/CHANGELOG.md +6 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- 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
|
+
};
|