@blamejs/blamejs-shop 0.4.23 → 0.4.25

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.
@@ -118,6 +118,70 @@ var TRANSFER_STATUSES = Object.freeze([
118
118
  var TRANSFER_ROLES = Object.freeze(["origin", "destination"]);
119
119
  var TRANSFER_ORDER_KEY = ["opened_at:desc", "id:desc"];
120
120
 
121
+ // The transfer lifecycle, modelled on b.fsm — the same composition the order
122
+ // and quote primitives use (lib/order.js#_getOrderFsm, lib/quotes.js). The FSM
123
+ // is the single source of truth for which (status, event) pairs are legal:
124
+ // every status-changing verb replays the machine from the row's current status
125
+ // and asks whether the edge is allowed before it touches the database, so an
126
+ // illegal transition is refused identically regardless of which surface fired
127
+ // it. Each event name maps 1:1 to the verb that fires it:
128
+ //
129
+ // ship open -> shipped (markShipped)
130
+ // transit shipped|in_transit -> in_transit (markInTransit; self-edge
131
+ // keeps the scan-beat log)
132
+ // receive shipped|in_transit -> received (markReceived)
133
+ // reconcile received -> reconciled (reconcile; credits dest)
134
+ // except open|shipped|in_transit|received
135
+ // -> exception (markException)
136
+ //
137
+ // Terminal states (reconciled, exception) declare no outgoing edge, so the
138
+ // machine itself refuses a double-reconcile / re-ship-after-exception without
139
+ // a hand-written status check.
140
+ var TRANSFER_TRANSITIONS = Object.freeze([
141
+ { from: "open", to: "shipped", on: "ship" },
142
+ { from: "shipped", to: "in_transit", on: "transit" },
143
+ { from: "in_transit", to: "in_transit", on: "transit" },
144
+ { from: "shipped", to: "received", on: "receive" },
145
+ { from: "in_transit", to: "received", on: "receive" },
146
+ { from: "received", to: "reconciled", on: "reconcile" },
147
+ { from: "open", to: "exception", on: "except" },
148
+ { from: "shipped", to: "exception", on: "except" },
149
+ { from: "in_transit", to: "exception", on: "except" },
150
+ { from: "received", to: "exception", on: "except" },
151
+ ]);
152
+
153
+ var _transferFsm = null;
154
+ function _getTransferFsm() {
155
+ if (_transferFsm) return _transferFsm;
156
+ // b.fsm emits audit events under the 'fsm' namespace — register it
157
+ // (idempotent) so the audit sink keeps the events instead of dropping them
158
+ // with a noisy warning, exactly as the order/quote FSMs do.
159
+ try { b.audit.registerNamespace("fsm"); } catch (_e) { /* idempotent; ignore */ }
160
+ _transferFsm = b.fsm.define({
161
+ name: "stock_transfer",
162
+ initial: "open",
163
+ states: {
164
+ open: {}, shipped: {}, in_transit: {},
165
+ received: {}, reconciled: {}, exception: {},
166
+ },
167
+ transitions: TRANSFER_TRANSITIONS.map(function (t) {
168
+ return { from: t.from, to: t.to, on: t.on };
169
+ }),
170
+ });
171
+ return _transferFsm;
172
+ }
173
+
174
+ // Validate one status-changing edge through the FSM. Returns true when the
175
+ // edge is legal from `fromStatus`, false otherwise. The verbs below use this
176
+ // to decide whether to issue the atomic claim-guard UPDATE — they keep
177
+ // throwing a TypeError on an illegal transition so the admin route's
178
+ // `e instanceof TypeError -> 400` mapping (admin.js#_transferAction) stays
179
+ // intact rather than surfacing a wrong-state as a 500.
180
+ function _canTransfer(fromStatus, event) {
181
+ var fsm = _getTransferFsm();
182
+ return fsm.restore({ state: fromStatus, history: [], context: {} }).can(event);
183
+ }
184
+
121
185
  // ---- validators ---------------------------------------------------------
122
186
 
123
187
  function _id(s, label) {
@@ -419,15 +483,21 @@ function create(opts) {
419
483
  if (!transfer) {
420
484
  throw new TypeError("stock-transfers.markShipped: transfer " + id + " not found");
421
485
  }
422
- if (transfer.status !== "open") {
486
+ if (!_canTransfer(transfer.status, "ship")) {
423
487
  throw new TypeError("stock-transfers.markShipped: transfer is " + transfer.status +
424
488
  ", only open transfers can be shipped");
425
489
  }
426
- await query(
490
+ // Claim the open -> shipped transition atomically — the WHERE status
491
+ // clause makes a concurrent double-ship a no-op for the loser.
492
+ var claim = await query(
427
493
  "UPDATE stock_transfers SET status = 'shipped', shipped_at = ?1, " +
428
- "carrier = ?2, tracking_number = ?3 WHERE id = ?4",
494
+ "carrier = ?2, tracking_number = ?3 WHERE id = ?4 AND status = 'open'",
429
495
  [shippedAt, carrier, tracking, id],
430
496
  );
497
+ if (Number(claim.rowCount || 0) !== 1) {
498
+ throw new TypeError("stock-transfers.markShipped: transfer " + id +
499
+ " is no longer open (shipped by a concurrent call)");
500
+ }
431
501
  await _writeEvent(id, "ship", transfer.from_location, {
432
502
  carrier: carrier, tracking_number: tracking,
433
503
  }, shippedAt);
@@ -448,13 +518,17 @@ function create(opts) {
448
518
  if (!transfer) {
449
519
  throw new TypeError("stock-transfers.markInTransit: transfer " + id + " not found");
450
520
  }
451
- if (transfer.status !== "shipped" && transfer.status !== "in_transit") {
521
+ if (!_canTransfer(transfer.status, "transit")) {
452
522
  throw new TypeError("stock-transfers.markInTransit: transfer is " + transfer.status +
453
523
  ", only shipped or in_transit transfers can record an in_transit scan");
454
524
  }
525
+ // Only the shipped -> in_transit edge flips status; the in_transit
526
+ // self-edge is an idempotent scan-beat that just appends an event. Claim
527
+ // the shipped -> in_transit flip with a WHERE status clause so two
528
+ // concurrent first-scans don't both believe they advanced the row.
455
529
  if (transfer.status === "shipped") {
456
530
  await query(
457
- "UPDATE stock_transfers SET status = 'in_transit' WHERE id = ?1",
531
+ "UPDATE stock_transfers SET status = 'in_transit' WHERE id = ?1 AND status = 'shipped'",
458
532
  [id],
459
533
  );
460
534
  }
@@ -495,7 +569,7 @@ function create(opts) {
495
569
  if (!transfer) {
496
570
  throw new TypeError("stock-transfers.markReceived: transfer " + id + " not found");
497
571
  }
498
- if (transfer.status !== "shipped" && transfer.status !== "in_transit") {
572
+ if (!_canTransfer(transfer.status, "receive")) {
499
573
  throw new TypeError("stock-transfers.markReceived: transfer is " + transfer.status +
500
574
  ", only shipped or in_transit transfers can be received");
501
575
  }
@@ -514,20 +588,44 @@ function create(opts) {
514
588
  " was not on the original transfer");
515
589
  }
516
590
  }
517
- // Walk every shipped line; write quantity_received (defaulting
518
- // to 0 for SKUs the operator didn't scan).
519
- for (var u = 0; u < transfer.lines.length; u += 1) {
520
- var line = transfer.lines[u];
521
- var got = Object.prototype.hasOwnProperty.call(rxMap, line.sku) ? rxMap[line.sku] : 0;
591
+ // Claim the (shipped|in_transit) -> received transition atomically
592
+ // BEFORE writing the per-line received quantities. Two concurrent
593
+ // receives both pass the read above, but only one UPDATE matches the
594
+ // expected status — the loser refuses, so a single set of
595
+ // quantity_received values lands and reconcile can't later credit twice.
596
+ var priorStatus = transfer.status;
597
+ var claim = await query(
598
+ "UPDATE stock_transfers SET status = 'received', received_at = ?1 " +
599
+ "WHERE id = ?2 AND status IN ('shipped', 'in_transit')",
600
+ [receivedAt, id],
601
+ );
602
+ if (Number(claim.rowCount || 0) !== 1) {
603
+ throw new TypeError("stock-transfers.markReceived: transfer " + id +
604
+ " is no longer shipped or in_transit (received by a concurrent call)");
605
+ }
606
+ // Walk every shipped line; write quantity_received (defaulting to 0 for
607
+ // SKUs the operator didn't scan). Each write is idempotent (the same
608
+ // quantity lands on a retry), so if a line throws the terminal claim is
609
+ // rolled back to its prior status — the transfer stays eligible for
610
+ // markReceived rather than stranding with partial / default quantities a
611
+ // later reconcile would then credit against.
612
+ try {
613
+ for (var u = 0; u < transfer.lines.length; u += 1) {
614
+ var line = transfer.lines[u];
615
+ var got = Object.prototype.hasOwnProperty.call(rxMap, line.sku) ? rxMap[line.sku] : 0;
616
+ await query(
617
+ "UPDATE stock_transfer_lines SET quantity_received = ?1 WHERE id = ?2",
618
+ [got, line.id],
619
+ );
620
+ }
621
+ } catch (e) {
522
622
  await query(
523
- "UPDATE stock_transfer_lines SET quantity_received = ?1 WHERE id = ?2",
524
- [got, line.id],
623
+ "UPDATE stock_transfers SET status = ?1, received_at = NULL " +
624
+ "WHERE id = ?2 AND status = 'received'",
625
+ [priorStatus, id],
525
626
  );
627
+ throw e;
526
628
  }
527
- await query(
528
- "UPDATE stock_transfers SET status = 'received', received_at = ?1 WHERE id = ?2",
529
- [receivedAt, id],
530
- );
531
629
  await _writeEvent(id, "receive", transfer.to_location, {
532
630
  received_lines: input.received_lines,
533
631
  }, receivedAt);
@@ -546,48 +644,74 @@ function create(opts) {
546
644
  if (!transfer) {
547
645
  throw new TypeError("stock-transfers.reconcile: transfer " + id + " not found");
548
646
  }
549
- if (transfer.status !== "received") {
647
+ // Refuse an illegal transition with a TypeError (kept for the route's
648
+ // 400 mapping). The status read here is only a fast-fail / clear-message
649
+ // path; the conditional claim-guard below is what actually serializes
650
+ // two concurrent reconciles.
651
+ if (!_canTransfer(transfer.status, "reconcile")) {
550
652
  throw new TypeError("stock-transfers.reconcile: transfer is " + transfer.status +
551
653
  ", only received transfers can be reconciled");
552
654
  }
553
655
  var ts = _now();
656
+ // Claim the received -> reconciled transition atomically BEFORE crediting
657
+ // the destination. Two concurrent reconciles both pass the read above,
658
+ // but only one UPDATE matches `status = 'received'` — the loser gets
659
+ // rowCount 0 and refuses, so the destination shelf is credited exactly
660
+ // once. (Without this guard both calls would credit, minting phantom
661
+ // inventory at the destination.)
662
+ var claim = await query(
663
+ "UPDATE stock_transfers SET status = 'reconciled', reconciled_at = ?1 " +
664
+ "WHERE id = ?2 AND status = 'received'",
665
+ [ts, id],
666
+ );
667
+ if (Number(claim.rowCount || 0) !== 1) {
668
+ throw new TypeError("stock-transfers.reconcile: transfer " + id +
669
+ " is no longer received (already reconciled by a concurrent call) — refusing to double-credit");
670
+ }
554
671
  var discrepancies = [];
555
- // Credit the destination one line at a time. A failure here
556
- // leaves the operator with a known-bad state: the receiving
557
- // shelf is partially credited and the transfer is still
558
- // 'received'. The operator can retry every adjustStock that
559
- // already landed shows up in the audit log so the second
560
- // attempt won't double-credit because the FSM gate refuses
561
- // reconcile on non-'received' status.
562
- for (var i = 0; i < transfer.lines.length; i += 1) {
563
- var line = transfer.lines[i];
564
- var rx = line.quantity_received == null ? 0 : line.quantity_received;
565
- if (rx > 0) {
566
- await locations.adjustStock({
567
- sku: line.sku,
568
- location_code: transfer.to_location,
569
- delta: rx,
570
- reason: "stock-transfer:reconcile:" + id,
571
- });
672
+ // Credit the destination one line at a time. The claim above flipped the
673
+ // status, so a concurrent call can't reach this loop for the same
674
+ // transfer. Each line is skipped once its discrepancy column is stamped,
675
+ // so a retry (after the compensation below rolls the claim back) re-credits
676
+ // ONLY the lines that hadn't landed yet no double-credit on the lines
677
+ // that already succeeded. If any line throws, the terminal claim is rolled
678
+ // back to 'received' so the transfer stays eligible for another reconcile
679
+ // that finishes the remaining credits, rather than stranding it.
680
+ try {
681
+ for (var i = 0; i < transfer.lines.length; i += 1) {
682
+ var line = transfer.lines[i];
683
+ if (line.discrepancy != null) continue; // already credited on a prior attempt
684
+ var rx = line.quantity_received == null ? 0 : line.quantity_received;
685
+ if (rx > 0) {
686
+ await locations.adjustStock({
687
+ sku: line.sku,
688
+ location_code: transfer.to_location,
689
+ delta: rx,
690
+ reason: "stock-transfer:reconcile:" + id,
691
+ });
692
+ }
693
+ var diff = line.quantity_shipped - rx;
694
+ await query(
695
+ "UPDATE stock_transfer_lines SET discrepancy = ?1 WHERE id = ?2",
696
+ [diff, line.id],
697
+ );
698
+ if (diff !== 0) {
699
+ discrepancies.push({
700
+ sku: line.sku,
701
+ quantity_shipped: line.quantity_shipped,
702
+ quantity_received: rx,
703
+ discrepancy: diff,
704
+ });
705
+ }
572
706
  }
573
- var diff = line.quantity_shipped - rx;
707
+ } catch (e) {
574
708
  await query(
575
- "UPDATE stock_transfer_lines SET discrepancy = ?1 WHERE id = ?2",
576
- [diff, line.id],
709
+ "UPDATE stock_transfers SET status = 'received', reconciled_at = NULL " +
710
+ "WHERE id = ?1 AND status = 'reconciled'",
711
+ [id],
577
712
  );
578
- if (diff !== 0) {
579
- discrepancies.push({
580
- sku: line.sku,
581
- quantity_shipped: line.quantity_shipped,
582
- quantity_received: rx,
583
- discrepancy: diff,
584
- });
585
- }
713
+ throw e;
586
714
  }
587
- await query(
588
- "UPDATE stock_transfers SET status = 'reconciled', reconciled_at = ?1 WHERE id = ?2",
589
- [ts, id],
590
- );
591
715
  await _writeEvent(id, "reconcile", transfer.to_location, {
592
716
  discrepancies: discrepancies,
593
717
  }, ts);
@@ -612,15 +736,23 @@ function create(opts) {
612
736
  if (!transfer) {
613
737
  throw new TypeError("stock-transfers.markException: transfer " + id + " not found");
614
738
  }
615
- if (transfer.status === "reconciled" || transfer.status === "exception") {
739
+ if (!_canTransfer(transfer.status, "except")) {
616
740
  throw new TypeError("stock-transfers.markException: transfer is " + transfer.status +
617
741
  ", terminal states cannot transition to exception");
618
742
  }
619
743
  var ts = _now();
620
- await query(
621
- "UPDATE stock_transfers SET status = 'exception', exception_reason = ?1 WHERE id = ?2",
744
+ // Claim the non-terminal -> exception transition atomically. The WHERE
745
+ // status clause refuses if a concurrent reconcile / exception already
746
+ // moved the row to a terminal state.
747
+ var claim = await query(
748
+ "UPDATE stock_transfers SET status = 'exception', exception_reason = ?1 " +
749
+ "WHERE id = ?2 AND status IN ('open', 'shipped', 'in_transit', 'received')",
622
750
  [reason, id],
623
751
  );
752
+ if (Number(claim.rowCount || 0) !== 1) {
753
+ throw new TypeError("stock-transfers.markException: transfer " + id +
754
+ " is no longer in a non-terminal state (settled by a concurrent call)");
755
+ }
624
756
  await _writeEvent(id, "exception", null, { reason: reason }, ts);
625
757
  return await _getHydrated(id);
626
758
  },