@blamejs/blamejs-shop 0.0.61 → 0.0.64

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,777 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.stockTransfers
4
+ * @title Stock transfers — audited multi-step stock movement between locations
5
+ *
6
+ * @intro
7
+ * The `inventoryLocations` primitive ships
8
+ * `transferStock({ sku, from_location, to_location, quantity })` —
9
+ * an atomic two-row write that moves stock between locations
10
+ * instantly. That verb is correct when the operator is fixing a
11
+ * typo, or moving stock between two bins inside the same warehouse,
12
+ * where no "in transit" window exists.
13
+ *
14
+ * This primitive is the COUNTERPART for moves that take physical
15
+ * time. A pallet leaves WH-EAST on Monday, lives on a truck for
16
+ * three days, lands at WH-WEST on Thursday. The operator wants:
17
+ *
18
+ * - The origin shelf debited immediately, so storefront routing
19
+ * doesn't keep selling stock that's on a truck.
20
+ * - The destination shelf credited only when receiving scans
21
+ * confirm the qty.
22
+ * - A discrepancy flag when received qty != shipped qty (loss,
23
+ * damage, mis-pick at origin).
24
+ * - An audit trail of every state change with operator-supplied
25
+ * reasons so the variance reconciliation has a paper trail.
26
+ *
27
+ * Lifecycle (six-state FSM):
28
+ *
29
+ * openTransfer({ from_location, to_location, lines, expected_eta?, reason })
30
+ * Validates locations exist + are distinct, validates lines,
31
+ * confirms origin has enough stock for every line, then
32
+ * decrements origin via `inventoryLocations.adjustStock(-qty)`
33
+ * on each SKU. On any decrement failure (insufficient stock,
34
+ * unknown location) the prior decrements are reversed so the
35
+ * origin shelf is restored to its pre-call state. Returns
36
+ * `{ id, status: 'open', ... }`.
37
+ *
38
+ * markShipped({ transfer_id, shipped_at?, carrier?, tracking_number? })
39
+ * open -> shipped. Captures carrier + tracking. shipped_at
40
+ * defaults to now.
41
+ *
42
+ * markInTransit({ transfer_id, location?, occurred_at? })
43
+ * shipped -> in_transit (or no-op when already in_transit; the
44
+ * event log still captures the scan beat — operators read the
45
+ * event log to answer "where was the pallet on Tuesday?").
46
+ * `location` is the operator-supplied scan location (a
47
+ * transshipment hub code, free text — not constrained to the
48
+ * inventory_locations set). Appends an event row regardless.
49
+ *
50
+ * markReceived({ transfer_id, received_lines, received_at })
51
+ * shipped|in_transit -> received. `received_lines` is the
52
+ * per-SKU `quantity_received` captured by the receiving scan.
53
+ * Refuses if any SKU in `received_lines` wasn't on the original
54
+ * transfer; missing SKUs in `received_lines` are treated as
55
+ * received=0 (operator forgot to scan, every shipped unit is
56
+ * discrepant — surfaces loudly at reconcile time).
57
+ *
58
+ * reconcile({ transfer_id })
59
+ * received -> reconciled. Walks the lines: for each one credits
60
+ * the destination with `quantity_received` via
61
+ * `inventoryLocations.adjustStock(+qty)` and stores
62
+ * `discrepancy = quantity_shipped - quantity_received`. Lines
63
+ * with `quantity_received === 0` skip the destination credit
64
+ * (no money created). The discrepancy column is non-null on
65
+ * every line after reconcile — operators read it to drive the
66
+ * variance report.
67
+ *
68
+ * markException({ transfer_id, reason })
69
+ * any non-terminal -> exception. Used when the pallet is lost
70
+ * or damaged. The origin stock has already been debited; the
71
+ * operator compensates via the existing
72
+ * `inventoryLocations.setStock` correction verb — the audit
73
+ * trail on `inventory_adjustments` captures the rationale and
74
+ * the variance report joins on `discrepanciesFor` for the
75
+ * per-transfer paper trail.
76
+ * Refuses if the transfer is already reconciled or in
77
+ * exception.
78
+ *
79
+ * Reads:
80
+ * getTransfer(id) — hydrated header + lines
81
+ * listOpen({ from_location?, to_location? }) — non-terminal transfers
82
+ * transfersForLocation({ location_code, role: 'origin'|'destination',
83
+ * limit?, cursor? }) — keyset-paginated
84
+ * discrepanciesFor(transfer_id) — per-SKU diff
85
+ *
86
+ * Composition:
87
+ * - b.uuid.v7 — transfer / line / event PKs (sortable)
88
+ * - b.guardUuid — strict UUID validation on every transfer_id
89
+ * - b.pagination — HMAC-tagged cursor for transfersForLocation
90
+ * - inventoryLocations — the SOLE owner of stock mutation; this
91
+ * primitive composes adjustStock to debit
92
+ * origin / credit destination, never
93
+ * writes to `inventory_stock` directly.
94
+ *
95
+ * Three-tier input validation (use the discipline; don't write the
96
+ * labels): every public verb here is a defensive request-shape
97
+ * reader OR a config-time entry point. Both throw on bad input.
98
+ * The FSM transitions are operator-driven entry points — they
99
+ * throw on bad state too.
100
+ */
101
+
102
+ var bShop;
103
+ function _b() {
104
+ if (!bShop) bShop = require("./index");
105
+ return bShop.framework;
106
+ }
107
+
108
+ // ---- constants ----------------------------------------------------------
109
+
110
+ var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
111
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
112
+ var CARRIER_RE = /^[\S\s]{1,128}$/;
113
+ var TRACKING_RE = /^[\S\s]{1,128}$/;
114
+ var SCAN_LOC_RE = /^[\S\s]{1,128}$/;
115
+ var MAX_REASON = 280;
116
+ var MAX_LINES = 1000;
117
+ var MAX_LIST_LIMIT = 200;
118
+
119
+ var TRANSFER_STATUSES = Object.freeze([
120
+ "open", "shipped", "in_transit", "received", "reconciled", "exception",
121
+ ]);
122
+ var TRANSFER_ROLES = Object.freeze(["origin", "destination"]);
123
+ var TRANSFER_ORDER_KEY = ["opened_at:desc", "id:desc"];
124
+
125
+ // ---- validators ---------------------------------------------------------
126
+
127
+ function _id(s, label) {
128
+ try {
129
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
130
+ } catch (e) {
131
+ throw new TypeError("stock-transfers: " + label + " — " + (e && e.message || "invalid UUID"));
132
+ }
133
+ }
134
+ function _code(s, label) {
135
+ if (typeof s !== "string" || !CODE_RE.test(s)) {
136
+ throw new TypeError("stock-transfers: " + (label || "location_code") +
137
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
138
+ }
139
+ }
140
+ function _sku(s) {
141
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
142
+ throw new TypeError("stock-transfers: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
143
+ }
144
+ }
145
+ function _positiveInt(n, label) {
146
+ if (!Number.isInteger(n) || n <= 0) {
147
+ throw new TypeError("stock-transfers: " + label + " must be a positive integer");
148
+ }
149
+ }
150
+ function _nonNegInt(n, label) {
151
+ if (!Number.isInteger(n) || n < 0) {
152
+ throw new TypeError("stock-transfers: " + label + " must be a non-negative integer");
153
+ }
154
+ }
155
+ function _reason(s, label) {
156
+ if (s == null) return "";
157
+ if (typeof s !== "string" || s.length > MAX_REASON) {
158
+ throw new TypeError("stock-transfers: " + (label || "reason") +
159
+ " must be a string ≤ " + MAX_REASON + " chars");
160
+ }
161
+ return s;
162
+ }
163
+ function _carrier(s) {
164
+ if (s == null) return null;
165
+ if (typeof s !== "string" || !CARRIER_RE.test(s) || s.length > 128) {
166
+ throw new TypeError("stock-transfers: carrier must be a string ≤ 128 chars");
167
+ }
168
+ return s;
169
+ }
170
+ function _tracking(s) {
171
+ if (s == null) return null;
172
+ if (typeof s !== "string" || !TRACKING_RE.test(s) || s.length > 128) {
173
+ throw new TypeError("stock-transfers: tracking_number must be a string ≤ 128 chars");
174
+ }
175
+ return s;
176
+ }
177
+ function _scanLocation(s) {
178
+ if (s == null) return null;
179
+ if (typeof s !== "string" || !SCAN_LOC_RE.test(s) || s.length > 128) {
180
+ throw new TypeError("stock-transfers: location must be a string ≤ 128 chars");
181
+ }
182
+ return s;
183
+ }
184
+ function _ts(n, label) {
185
+ if (n == null) return null;
186
+ if (!Number.isInteger(n) || n < 0) {
187
+ throw new TypeError("stock-transfers: " + label + " must be a non-negative integer (epoch ms)");
188
+ }
189
+ return n;
190
+ }
191
+ function _limit(n) {
192
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
193
+ throw new TypeError("stock-transfers: limit must be an integer in 1..." + MAX_LIST_LIMIT);
194
+ }
195
+ }
196
+
197
+ function _now() { return Date.now(); }
198
+
199
+ // ---- factory ------------------------------------------------------------
200
+
201
+ function create(opts) {
202
+ opts = opts || {};
203
+ // The inventoryLocations primitive is the sole owner of
204
+ // `inventory_stock` mutations. This primitive composes its
205
+ // adjustStock verb to debit origin / credit destination — refusing
206
+ // to wire one through fails loud at boot rather than at first call.
207
+ if (!opts.inventoryLocations ||
208
+ typeof opts.inventoryLocations.adjustStock !== "function" ||
209
+ typeof opts.inventoryLocations.getLocation !== "function" ||
210
+ typeof opts.inventoryLocations.stockForSku !== "function") {
211
+ throw new TypeError("stock-transfers.create: opts.inventoryLocations with " +
212
+ "adjustStock + getLocation + stockForSku is required");
213
+ }
214
+ var locations = opts.inventoryLocations;
215
+ var query = opts.query;
216
+ if (!query) {
217
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
218
+ }
219
+ // Pagination cursors are HMAC-tagged so an operator can't hand-
220
+ // craft one to skip ahead or replay across deployments. Tests
221
+ // inject a fixed dev string; production must supply an explicit
222
+ // secret.
223
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
224
+ if (process.env.NODE_ENV === "production") {
225
+ throw new Error("stock-transfers.create: opts.cursorSecret is required in production");
226
+ }
227
+ opts.cursorSecret = "stock-transfers-cursor-secret-dev-only";
228
+ }
229
+ var cursorSecret = opts.cursorSecret;
230
+
231
+ // Validate the `lines` array shape at openTransfer time. Returns
232
+ // normalized lines (defensive copies so the caller's object isn't
233
+ // mutated downstream).
234
+ function _validateOpenLines(rawLines) {
235
+ if (!Array.isArray(rawLines) || rawLines.length === 0) {
236
+ throw new TypeError("stock-transfers.openTransfer: lines must be a non-empty array");
237
+ }
238
+ if (rawLines.length > MAX_LINES) {
239
+ throw new TypeError("stock-transfers.openTransfer: lines must contain ≤ " + MAX_LINES + " entries");
240
+ }
241
+ var seen = Object.create(null);
242
+ var normalized = [];
243
+ for (var i = 0; i < rawLines.length; i += 1) {
244
+ var l = rawLines[i];
245
+ if (!l || typeof l !== "object") {
246
+ throw new TypeError("stock-transfers.openTransfer: lines[" + i + "] must be an object");
247
+ }
248
+ _sku(l.sku);
249
+ _positiveInt(l.quantity, "lines[" + i + "].quantity");
250
+ if (seen[l.sku]) {
251
+ throw new TypeError("stock-transfers.openTransfer: duplicate sku " +
252
+ JSON.stringify(l.sku) + " in lines");
253
+ }
254
+ seen[l.sku] = true;
255
+ normalized.push({ sku: l.sku, quantity: l.quantity });
256
+ }
257
+ return normalized;
258
+ }
259
+
260
+ // Persist one event-log row. Detail payload is JSON-serialized so
261
+ // downstream readers parse one column instead of a polymorphic
262
+ // per-event-type table.
263
+ async function _writeEvent(transferId, eventType, scanLocation, detail, occurredAt) {
264
+ var json = detail == null ? null : JSON.stringify(detail);
265
+ await query(
266
+ "INSERT INTO stock_transfer_events (id, transfer_id, event_type, location, detail_json, occurred_at) " +
267
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
268
+ [_b().uuid.v7(), transferId, eventType, scanLocation, json, occurredAt],
269
+ );
270
+ }
271
+
272
+ // Hydrate a transfer row + its lines + a parsed `lines` array.
273
+ // Returns null on miss so the caller-handler maps cleanly to HTTP 404.
274
+ async function _getHydrated(id) {
275
+ var rRow = await query("SELECT * FROM stock_transfers WHERE id = ?1", [id]);
276
+ if (!rRow.rows.length) return null;
277
+ var transfer = rRow.rows[0];
278
+ var rLines = await query(
279
+ "SELECT * FROM stock_transfer_lines WHERE transfer_id = ?1 ORDER BY sku ASC",
280
+ [id],
281
+ );
282
+ transfer.lines = rLines.rows;
283
+ return transfer;
284
+ }
285
+
286
+ return {
287
+
288
+ // Open a new transfer. Validates locations + lines, then debits
289
+ // the origin shelf one SKU at a time via inventoryLocations.
290
+ // adjustStock(-qty). If any debit fails, every prior debit is
291
+ // restored so the origin shelf returns to its pre-call state and
292
+ // no header row is persisted. Returns the hydrated transfer.
293
+ openTransfer: async function (input) {
294
+ if (!input || typeof input !== "object") {
295
+ throw new TypeError("stock-transfers.openTransfer: input object required");
296
+ }
297
+ _code(input.from_location, "from_location");
298
+ _code(input.to_location, "to_location");
299
+ if (input.from_location === input.to_location) {
300
+ throw new TypeError("stock-transfers.openTransfer: from_location and to_location must differ");
301
+ }
302
+ var lines = _validateOpenLines(input.lines);
303
+ var reason = _reason(input.reason);
304
+ var expectedEta = _ts(input.expected_eta, "expected_eta");
305
+
306
+ var fromLoc = await locations.getLocation(input.from_location);
307
+ if (!fromLoc) {
308
+ throw new TypeError("stock-transfers.openTransfer: from_location " +
309
+ JSON.stringify(input.from_location) + " not found");
310
+ }
311
+ var toLoc = await locations.getLocation(input.to_location);
312
+ if (!toLoc) {
313
+ throw new TypeError("stock-transfers.openTransfer: to_location " +
314
+ JSON.stringify(input.to_location) + " not found");
315
+ }
316
+
317
+ // Pre-flight: confirm origin has enough stock for every line
318
+ // before debiting anything. Fails fast with a single clear
319
+ // error rather than relying on adjustStock to refuse mid-loop
320
+ // and forcing the compensating-restore path on the common case.
321
+ for (var p = 0; p < lines.length; p += 1) {
322
+ var ln = lines[p];
323
+ var sfs = await locations.stockForSku(ln.sku);
324
+ var atOrigin = 0;
325
+ for (var q = 0; q < sfs.by_location.length; q += 1) {
326
+ if (sfs.by_location[q].code === input.from_location) {
327
+ atOrigin = sfs.by_location[q].quantity;
328
+ break;
329
+ }
330
+ }
331
+ if (atOrigin < ln.quantity) {
332
+ throw new TypeError("stock-transfers.openTransfer: insufficient stock at " +
333
+ input.from_location + " for sku " + ln.sku +
334
+ " (have " + atOrigin + ", need " + ln.quantity + ")");
335
+ }
336
+ }
337
+
338
+ var id = _b().uuid.v7();
339
+ var ts = _now();
340
+
341
+ // Debit origin one line at a time. On failure (race with a
342
+ // concurrent sale, for instance), restore every successful
343
+ // debit so the operator can retry without phantom inventory
344
+ // sitting in a half-opened transfer.
345
+ var debited = [];
346
+ try {
347
+ for (var i = 0; i < lines.length; i += 1) {
348
+ await locations.adjustStock({
349
+ sku: lines[i].sku,
350
+ location_code: input.from_location,
351
+ delta: -lines[i].quantity,
352
+ reason: "stock-transfer:open:" + id,
353
+ });
354
+ debited.push(lines[i]);
355
+ }
356
+ } catch (e) {
357
+ for (var j = debited.length - 1; j >= 0; j -= 1) {
358
+ try {
359
+ await locations.adjustStock({
360
+ sku: debited[j].sku,
361
+ location_code: input.from_location,
362
+ delta: debited[j].quantity,
363
+ reason: "stock-transfer:open:rollback:" + id,
364
+ });
365
+ } catch (_e2) { /* drop-silent — the original error is what the caller needs to fix */ }
366
+ }
367
+ throw e;
368
+ }
369
+
370
+ // Persist the header + lines + open event. The FK + CASCADE
371
+ // means a header-insert failure that lands AFTER the lines
372
+ // insert is impossible — the header is the parent. Any DB
373
+ // failure here also triggers the origin-restore compensating
374
+ // path so the shelf returns to its pre-call state.
375
+ try {
376
+ await query(
377
+ "INSERT INTO stock_transfers (id, from_location, to_location, status, reason, " +
378
+ "expected_eta, opened_at) VALUES (?1, ?2, ?3, 'open', ?4, ?5, ?6)",
379
+ [id, input.from_location, input.to_location, reason, expectedEta, ts],
380
+ );
381
+ for (var k = 0; k < lines.length; k += 1) {
382
+ await query(
383
+ "INSERT INTO stock_transfer_lines (id, transfer_id, sku, quantity_shipped) " +
384
+ "VALUES (?1, ?2, ?3, ?4)",
385
+ [_b().uuid.v7(), id, lines[k].sku, lines[k].quantity],
386
+ );
387
+ }
388
+ await _writeEvent(id, "open", input.from_location, {
389
+ lines: lines, reason: reason,
390
+ }, ts);
391
+ } catch (e2) {
392
+ try { await query("DELETE FROM stock_transfers WHERE id = ?1", [id]); }
393
+ catch (_e3) { /* drop-silent — the original error is what the operator needs */ }
394
+ for (var m = lines.length - 1; m >= 0; m -= 1) {
395
+ try {
396
+ await locations.adjustStock({
397
+ sku: lines[m].sku,
398
+ location_code: input.from_location,
399
+ delta: lines[m].quantity,
400
+ reason: "stock-transfer:open:rollback:" + id,
401
+ });
402
+ } catch (_e4) { /* drop-silent — original error is the operator's signal */ }
403
+ }
404
+ throw e2;
405
+ }
406
+
407
+ return await _getHydrated(id);
408
+ },
409
+
410
+ // open -> shipped. Captures carrier + tracking number. Refuses
411
+ // if the transfer is not in 'open' state — markShipped twice is
412
+ // not idempotent (operators that want a retry call getTransfer
413
+ // first to see current state).
414
+ markShipped: async function (input) {
415
+ if (!input || typeof input !== "object") {
416
+ throw new TypeError("stock-transfers.markShipped: input object required");
417
+ }
418
+ var id = _id(input.transfer_id, "transfer_id");
419
+ var carrier = _carrier(input.carrier);
420
+ var tracking = _tracking(input.tracking_number);
421
+ var shippedAt = input.shipped_at == null ? _now() : _ts(input.shipped_at, "shipped_at");
422
+ var transfer = await _getHydrated(id);
423
+ if (!transfer) {
424
+ throw new TypeError("stock-transfers.markShipped: transfer " + id + " not found");
425
+ }
426
+ if (transfer.status !== "open") {
427
+ throw new TypeError("stock-transfers.markShipped: transfer is " + transfer.status +
428
+ ", only open transfers can be shipped");
429
+ }
430
+ await query(
431
+ "UPDATE stock_transfers SET status = 'shipped', shipped_at = ?1, " +
432
+ "carrier = ?2, tracking_number = ?3 WHERE id = ?4",
433
+ [shippedAt, carrier, tracking, id],
434
+ );
435
+ await _writeEvent(id, "ship", transfer.from_location, {
436
+ carrier: carrier, tracking_number: tracking,
437
+ }, shippedAt);
438
+ return await _getHydrated(id);
439
+ },
440
+
441
+ // shipped|in_transit -> in_transit. Idempotent on in_transit —
442
+ // the event log still captures the scan beat so operators can
443
+ // answer "where was the pallet on Tuesday?".
444
+ markInTransit: async function (input) {
445
+ if (!input || typeof input !== "object") {
446
+ throw new TypeError("stock-transfers.markInTransit: input object required");
447
+ }
448
+ var id = _id(input.transfer_id, "transfer_id");
449
+ var scanLoc = _scanLocation(input.location);
450
+ var occurredAt = input.occurred_at == null ? _now() : _ts(input.occurred_at, "occurred_at");
451
+ var transfer = await _getHydrated(id);
452
+ if (!transfer) {
453
+ throw new TypeError("stock-transfers.markInTransit: transfer " + id + " not found");
454
+ }
455
+ if (transfer.status !== "shipped" && transfer.status !== "in_transit") {
456
+ throw new TypeError("stock-transfers.markInTransit: transfer is " + transfer.status +
457
+ ", only shipped or in_transit transfers can record an in_transit scan");
458
+ }
459
+ if (transfer.status === "shipped") {
460
+ await query(
461
+ "UPDATE stock_transfers SET status = 'in_transit' WHERE id = ?1",
462
+ [id],
463
+ );
464
+ }
465
+ await _writeEvent(id, "in_transit", scanLoc, null, occurredAt);
466
+ return await _getHydrated(id);
467
+ },
468
+
469
+ // shipped|in_transit -> received. Captures per-SKU
470
+ // quantity_received. SKUs not in `received_lines` are recorded
471
+ // as received=0 (the operator forgot to scan; every shipped
472
+ // unit will be flagged discrepant at reconcile time).
473
+ markReceived: async function (input) {
474
+ if (!input || typeof input !== "object") {
475
+ throw new TypeError("stock-transfers.markReceived: input object required");
476
+ }
477
+ var id = _id(input.transfer_id, "transfer_id");
478
+ if (!Array.isArray(input.received_lines)) {
479
+ throw new TypeError("stock-transfers.markReceived: received_lines must be an array");
480
+ }
481
+ var receivedAt = input.received_at == null ? _now() : _ts(input.received_at, "received_at");
482
+ // Validate received_lines shape up-front; build a sku -> qty
483
+ // map for the per-line UPDATE pass.
484
+ var rxMap = Object.create(null);
485
+ for (var i = 0; i < input.received_lines.length; i += 1) {
486
+ var rl = input.received_lines[i];
487
+ if (!rl || typeof rl !== "object") {
488
+ throw new TypeError("stock-transfers.markReceived: received_lines[" + i + "] must be an object");
489
+ }
490
+ _sku(rl.sku);
491
+ _nonNegInt(rl.quantity_received, "received_lines[" + i + "].quantity_received");
492
+ if (Object.prototype.hasOwnProperty.call(rxMap, rl.sku)) {
493
+ throw new TypeError("stock-transfers.markReceived: duplicate sku " +
494
+ JSON.stringify(rl.sku) + " in received_lines");
495
+ }
496
+ rxMap[rl.sku] = rl.quantity_received;
497
+ }
498
+ var transfer = await _getHydrated(id);
499
+ if (!transfer) {
500
+ throw new TypeError("stock-transfers.markReceived: transfer " + id + " not found");
501
+ }
502
+ if (transfer.status !== "shipped" && transfer.status !== "in_transit") {
503
+ throw new TypeError("stock-transfers.markReceived: transfer is " + transfer.status +
504
+ ", only shipped or in_transit transfers can be received");
505
+ }
506
+ // Refuse SKUs that weren't on the original transfer. Missing
507
+ // SKUs (in shipped lines but not received_lines) are not a
508
+ // refusal — they end up with quantity_received = 0, which
509
+ // shows up as a discrepancy at reconcile time.
510
+ var shippedSkus = Object.create(null);
511
+ for (var s = 0; s < transfer.lines.length; s += 1) {
512
+ shippedSkus[transfer.lines[s].sku] = true;
513
+ }
514
+ var rxSkus = Object.keys(rxMap);
515
+ for (var t = 0; t < rxSkus.length; t += 1) {
516
+ if (!shippedSkus[rxSkus[t]]) {
517
+ throw new TypeError("stock-transfers.markReceived: sku " + JSON.stringify(rxSkus[t]) +
518
+ " was not on the original transfer");
519
+ }
520
+ }
521
+ // Walk every shipped line; write quantity_received (defaulting
522
+ // to 0 for SKUs the operator didn't scan).
523
+ for (var u = 0; u < transfer.lines.length; u += 1) {
524
+ var line = transfer.lines[u];
525
+ var got = Object.prototype.hasOwnProperty.call(rxMap, line.sku) ? rxMap[line.sku] : 0;
526
+ await query(
527
+ "UPDATE stock_transfer_lines SET quantity_received = ?1 WHERE id = ?2",
528
+ [got, line.id],
529
+ );
530
+ }
531
+ await query(
532
+ "UPDATE stock_transfers SET status = 'received', received_at = ?1 WHERE id = ?2",
533
+ [receivedAt, id],
534
+ );
535
+ await _writeEvent(id, "receive", transfer.to_location, {
536
+ received_lines: input.received_lines,
537
+ }, receivedAt);
538
+ return await _getHydrated(id);
539
+ },
540
+
541
+ // received -> reconciled. Credits the destination per line and
542
+ // stamps the discrepancy column. Lines with quantity_received=0
543
+ // skip the destination credit (no money created).
544
+ reconcile: async function (input) {
545
+ if (!input || typeof input !== "object") {
546
+ throw new TypeError("stock-transfers.reconcile: input object required");
547
+ }
548
+ var id = _id(input.transfer_id, "transfer_id");
549
+ var transfer = await _getHydrated(id);
550
+ if (!transfer) {
551
+ throw new TypeError("stock-transfers.reconcile: transfer " + id + " not found");
552
+ }
553
+ if (transfer.status !== "received") {
554
+ throw new TypeError("stock-transfers.reconcile: transfer is " + transfer.status +
555
+ ", only received transfers can be reconciled");
556
+ }
557
+ var ts = _now();
558
+ var discrepancies = [];
559
+ // Credit the destination one line at a time. A failure here
560
+ // leaves the operator with a known-bad state: the receiving
561
+ // shelf is partially credited and the transfer is still
562
+ // 'received'. The operator can retry — every adjustStock that
563
+ // already landed shows up in the audit log so the second
564
+ // attempt won't double-credit because the FSM gate refuses
565
+ // reconcile on non-'received' status.
566
+ for (var i = 0; i < transfer.lines.length; i += 1) {
567
+ var line = transfer.lines[i];
568
+ var rx = line.quantity_received == null ? 0 : line.quantity_received;
569
+ if (rx > 0) {
570
+ await locations.adjustStock({
571
+ sku: line.sku,
572
+ location_code: transfer.to_location,
573
+ delta: rx,
574
+ reason: "stock-transfer:reconcile:" + id,
575
+ });
576
+ }
577
+ var diff = line.quantity_shipped - rx;
578
+ await query(
579
+ "UPDATE stock_transfer_lines SET discrepancy = ?1 WHERE id = ?2",
580
+ [diff, line.id],
581
+ );
582
+ if (diff !== 0) {
583
+ discrepancies.push({
584
+ sku: line.sku,
585
+ quantity_shipped: line.quantity_shipped,
586
+ quantity_received: rx,
587
+ discrepancy: diff,
588
+ });
589
+ }
590
+ }
591
+ await query(
592
+ "UPDATE stock_transfers SET status = 'reconciled', reconciled_at = ?1 WHERE id = ?2",
593
+ [ts, id],
594
+ );
595
+ await _writeEvent(id, "reconcile", transfer.to_location, {
596
+ discrepancies: discrepancies,
597
+ }, ts);
598
+ return await _getHydrated(id);
599
+ },
600
+
601
+ // any non-terminal -> exception. Lost / damaged / disputed. The
602
+ // origin stock has already been debited at open; the operator
603
+ // compensates via a separate inventoryLocations.setStock
604
+ // correction so the audit trail on inventory_adjustments carries
605
+ // the rationale alongside the discrepanciesFor variance report.
606
+ markException: async function (input) {
607
+ if (!input || typeof input !== "object") {
608
+ throw new TypeError("stock-transfers.markException: input object required");
609
+ }
610
+ var id = _id(input.transfer_id, "transfer_id");
611
+ var reason = _reason(input.reason, "exception reason");
612
+ if (!reason.length) {
613
+ throw new TypeError("stock-transfers.markException: reason must be a non-empty string");
614
+ }
615
+ var transfer = await _getHydrated(id);
616
+ if (!transfer) {
617
+ throw new TypeError("stock-transfers.markException: transfer " + id + " not found");
618
+ }
619
+ if (transfer.status === "reconciled" || transfer.status === "exception") {
620
+ throw new TypeError("stock-transfers.markException: transfer is " + transfer.status +
621
+ ", terminal states cannot transition to exception");
622
+ }
623
+ var ts = _now();
624
+ await query(
625
+ "UPDATE stock_transfers SET status = 'exception', exception_reason = ?1 WHERE id = ?2",
626
+ [reason, id],
627
+ );
628
+ await _writeEvent(id, "exception", null, { reason: reason }, ts);
629
+ return await _getHydrated(id);
630
+ },
631
+
632
+ // Read a hydrated transfer or null on miss.
633
+ getTransfer: async function (transferId) {
634
+ var id = _id(transferId, "transfer_id");
635
+ return await _getHydrated(id);
636
+ },
637
+
638
+ // List non-terminal transfers (open / shipped / in_transit /
639
+ // received). Optionally scoped to one origin or destination.
640
+ // Ordered by (opened_at DESC, id DESC) — operators read the
641
+ // freshest transfers at the top.
642
+ listOpen: async function (listOpts) {
643
+ listOpts = listOpts || {};
644
+ var hasFrom = listOpts.from_location !== undefined && listOpts.from_location !== null;
645
+ var hasTo = listOpts.to_location !== undefined && listOpts.to_location !== null;
646
+ if (hasFrom) _code(listOpts.from_location, "from_location");
647
+ if (hasTo) _code(listOpts.to_location, "to_location");
648
+ var openStates = "('open','shipped','in_transit','received')";
649
+ var sql, params;
650
+ if (hasFrom && hasTo) {
651
+ sql = "SELECT * FROM stock_transfers WHERE from_location = ?1 AND to_location = ?2 " +
652
+ "AND status IN " + openStates + " ORDER BY opened_at DESC, id DESC";
653
+ params = [listOpts.from_location, listOpts.to_location];
654
+ } else if (hasFrom) {
655
+ sql = "SELECT * FROM stock_transfers WHERE from_location = ?1 " +
656
+ "AND status IN " + openStates + " ORDER BY opened_at DESC, id DESC";
657
+ params = [listOpts.from_location];
658
+ } else if (hasTo) {
659
+ sql = "SELECT * FROM stock_transfers WHERE to_location = ?1 " +
660
+ "AND status IN " + openStates + " ORDER BY opened_at DESC, id DESC";
661
+ params = [listOpts.to_location];
662
+ } else {
663
+ sql = "SELECT * FROM stock_transfers WHERE status IN " + openStates +
664
+ " ORDER BY opened_at DESC, id DESC";
665
+ params = [];
666
+ }
667
+ var rows = (await query(sql, params)).rows;
668
+ // Hydrate lines per row so the admin UI doesn't need a fan-out
669
+ // fetch. For larger pages this is N+1 reads; the indexed
670
+ // (transfer_id) lookup keeps each one cheap.
671
+ for (var i = 0; i < rows.length; i += 1) {
672
+ var rL = await query(
673
+ "SELECT * FROM stock_transfer_lines WHERE transfer_id = ?1 ORDER BY sku ASC",
674
+ [rows[i].id],
675
+ );
676
+ rows[i].lines = rL.rows;
677
+ }
678
+ return rows;
679
+ },
680
+
681
+ // Paginated list of transfers where `location_code` is either the
682
+ // origin or destination (caller picks via `role`). Cursor is
683
+ // HMAC-tagged so an operator can't tamper or replay across
684
+ // orderKey changes. Mirrors the cursor shape used by
685
+ // inventory-receive.list / order-notes.listForOrder.
686
+ transfersForLocation: async function (listOpts) {
687
+ if (!listOpts || typeof listOpts !== "object") {
688
+ throw new TypeError("stock-transfers.transfersForLocation: opts object required");
689
+ }
690
+ _code(listOpts.location_code, "location_code");
691
+ if (TRANSFER_ROLES.indexOf(listOpts.role) === -1) {
692
+ throw new TypeError("stock-transfers.transfersForLocation: role must be one of " +
693
+ TRANSFER_ROLES.join(", ") + ", got " + JSON.stringify(listOpts.role));
694
+ }
695
+ var limit = listOpts.limit == null ? 50 : listOpts.limit;
696
+ _limit(limit);
697
+ var cursorVals = null;
698
+ if (listOpts.cursor != null) {
699
+ if (typeof listOpts.cursor !== "string") {
700
+ throw new TypeError("stock-transfers.transfersForLocation: cursor must be an opaque string or null");
701
+ }
702
+ try {
703
+ var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
704
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(TRANSFER_ORDER_KEY)) {
705
+ throw new TypeError("stock-transfers.transfersForLocation: cursor orderKey mismatch");
706
+ }
707
+ cursorVals = state.vals;
708
+ } catch (e) {
709
+ if (e instanceof TypeError) throw e;
710
+ throw new TypeError("stock-transfers.transfersForLocation: cursor — " + (e && e.message || "malformed"));
711
+ }
712
+ }
713
+ var col = listOpts.role === "origin" ? "from_location" : "to_location";
714
+ var sql, params;
715
+ if (cursorVals) {
716
+ sql = "SELECT * FROM stock_transfers WHERE " + col + " = ?1 AND " +
717
+ "(opened_at < ?2 OR (opened_at = ?2 AND id < ?3)) " +
718
+ "ORDER BY opened_at DESC, id DESC LIMIT ?4";
719
+ params = [listOpts.location_code, cursorVals[0], cursorVals[1], limit];
720
+ } else {
721
+ sql = "SELECT * FROM stock_transfers WHERE " + col + " = ?1 " +
722
+ "ORDER BY opened_at DESC, id DESC LIMIT ?2";
723
+ params = [listOpts.location_code, limit];
724
+ }
725
+ var rows = (await query(sql, params)).rows;
726
+ for (var i = 0; i < rows.length; i += 1) {
727
+ var rL = await query(
728
+ "SELECT * FROM stock_transfer_lines WHERE transfer_id = ?1 ORDER BY sku ASC",
729
+ [rows[i].id],
730
+ );
731
+ rows[i].lines = rL.rows;
732
+ }
733
+ var last = rows[rows.length - 1];
734
+ var next = null;
735
+ if (last && rows.length === limit) {
736
+ next = _b().pagination.encodeCursor({
737
+ orderKey: TRANSFER_ORDER_KEY,
738
+ vals: [last.opened_at, last.id],
739
+ forward: true,
740
+ }, cursorSecret);
741
+ }
742
+ return { rows: rows, next_cursor: next };
743
+ },
744
+
745
+ // Per-SKU shipped/received/discrepancy view. Returns every line
746
+ // on the transfer regardless of whether it has a discrepancy —
747
+ // operators reading this verb want the full picture, including
748
+ // the zero-diff lines, so the variance report doesn't omit the
749
+ // "this part came through clean" rows.
750
+ discrepanciesFor: async function (transferId) {
751
+ var id = _id(transferId, "transfer_id");
752
+ var transfer = await _getHydrated(id);
753
+ if (!transfer) return null;
754
+ var out = [];
755
+ for (var i = 0; i < transfer.lines.length; i += 1) {
756
+ var line = transfer.lines[i];
757
+ var rx = line.quantity_received == null ? null : line.quantity_received;
758
+ var diff = line.discrepancy == null
759
+ ? (rx == null ? null : line.quantity_shipped - rx)
760
+ : line.discrepancy;
761
+ out.push({
762
+ sku: line.sku,
763
+ quantity_shipped: line.quantity_shipped,
764
+ quantity_received: rx,
765
+ discrepancy: diff,
766
+ });
767
+ }
768
+ return out;
769
+ },
770
+ };
771
+ }
772
+
773
+ module.exports = {
774
+ create: create,
775
+ TRANSFER_STATUSES: TRANSFER_STATUSES,
776
+ TRANSFER_ROLES: TRANSFER_ROLES,
777
+ };