@blamejs/blamejs-shop 0.4.61 → 0.4.63
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 +4 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/cart-bulk-ops.js +15 -4
- package/lib/customer-impersonation.js +26 -3
- package/lib/customer-merge.js +109 -28
- package/lib/operator-approvals.js +16 -2
- package/lib/order-exchanges.js +49 -18
- package/lib/payment-retries.js +35 -0
- package/lib/pick-lists.js +60 -22
- package/lib/plan-changes.js +21 -2
- package/lib/preorder.js +163 -40
- package/lib/print-queue.js +18 -2
- package/lib/purchase-orders.js +131 -36
- package/lib/seller-signup.js +37 -6
- package/lib/split-shipments.js +57 -16
- package/lib/suggestion-box.js +35 -2
- package/package.json +1 -1
package/lib/preorder.js
CHANGED
|
@@ -245,29 +245,67 @@ function create(opts) {
|
|
|
245
245
|
// order surface they used). The per-campaign counter is NOT
|
|
246
246
|
// decremented on conversion — the reserved capacity is consumed by
|
|
247
247
|
// the order, not freed.
|
|
248
|
+
//
|
|
249
|
+
// Returns `{ won: <bool>, orderId }`. `won` is false when the
|
|
250
|
+
// conditional claim lost the race (the row was already flipped out
|
|
251
|
+
// of 'active' by a concurrent converter) — the caller skips the
|
|
252
|
+
// exactly-once side-effect for that row.
|
|
253
|
+
//
|
|
254
|
+
// Concurrency: the status flip is the atomic claim and runs FIRST,
|
|
255
|
+
// gated by `AND status = 'active'`, so two concurrent converters
|
|
256
|
+
// can't both mint an order for the same reservation. SQLite
|
|
257
|
+
// serialises writers, so exactly one UPDATE matches; the loser sees
|
|
258
|
+
// rowCount 0 and bails without calling `createFromCart`. When the
|
|
259
|
+
// order side-effect throws after the claim, the claim is reverted
|
|
260
|
+
// (self-targeting `WHERE id=? AND status='converted'`) so the row
|
|
261
|
+
// isn't stranded in 'converted' with no backing order.
|
|
248
262
|
async function _convertOne(reservation, campaign, ts) {
|
|
263
|
+
var claim = await query(
|
|
264
|
+
"UPDATE preorder_reservations SET status = 'converted', converted_at = ?1 " +
|
|
265
|
+
"WHERE id = ?2 AND status = 'active'",
|
|
266
|
+
[ts, reservation.id],
|
|
267
|
+
);
|
|
268
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
269
|
+
// Lost the race — a concurrent converter already claimed this
|
|
270
|
+
// reservation. Skip the side-effect; the winner mints the order.
|
|
271
|
+
return { won: false, orderId: null };
|
|
272
|
+
}
|
|
249
273
|
var convertedOrderId = null;
|
|
250
274
|
if (orderHandle) {
|
|
251
|
-
var orderResult
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
275
|
+
var orderResult;
|
|
276
|
+
try {
|
|
277
|
+
orderResult = await orderHandle.createFromCart({
|
|
278
|
+
customer_id: reservation.customer_id,
|
|
279
|
+
lines: [{
|
|
280
|
+
sku: campaign.sku,
|
|
281
|
+
variant_id: campaign.variant_id,
|
|
282
|
+
quantity: reservation.quantity,
|
|
283
|
+
unit_price_minor: campaign.full_price_minor,
|
|
284
|
+
currency: campaign.currency,
|
|
285
|
+
}],
|
|
286
|
+
preorder_reservation_id: reservation.id,
|
|
287
|
+
preorder_campaign_slug: campaign.slug,
|
|
288
|
+
});
|
|
289
|
+
} catch (e) {
|
|
290
|
+
// The order create failed after we claimed the row — revert
|
|
291
|
+
// the claim so the reservation returns to 'active' and a retry
|
|
292
|
+
// (or the next launch sweep) can convert it cleanly.
|
|
293
|
+
await query(
|
|
294
|
+
"UPDATE preorder_reservations SET status = 'active', converted_at = NULL " +
|
|
295
|
+
"WHERE id = ?1 AND status = 'converted'",
|
|
296
|
+
[reservation.id],
|
|
297
|
+
);
|
|
298
|
+
throw e;
|
|
299
|
+
}
|
|
263
300
|
convertedOrderId = orderResult && orderResult.id ? orderResult.id : null;
|
|
264
301
|
}
|
|
302
|
+
// Backfill the order id onto the already-claimed row.
|
|
265
303
|
await query(
|
|
266
|
-
"UPDATE preorder_reservations SET
|
|
267
|
-
"
|
|
268
|
-
[
|
|
304
|
+
"UPDATE preorder_reservations SET converted_order_id = ?1 " +
|
|
305
|
+
"WHERE id = ?2 AND status = 'converted'",
|
|
306
|
+
[convertedOrderId, reservation.id],
|
|
269
307
|
);
|
|
270
|
-
return convertedOrderId;
|
|
308
|
+
return { won: true, orderId: convertedOrderId };
|
|
271
309
|
}
|
|
272
310
|
|
|
273
311
|
return {
|
|
@@ -369,6 +407,15 @@ function create(opts) {
|
|
|
369
407
|
throw new TypeError("preorder.reserve: campaign is " + campaign.status +
|
|
370
408
|
", only active campaigns accept reservations");
|
|
371
409
|
}
|
|
410
|
+
// The JS cap pre-check below stays for a precise, friendly error
|
|
411
|
+
// message on the common (uncontended) path — but it is NOT the
|
|
412
|
+
// guard. Two concurrent reserves could both pass this read-time
|
|
413
|
+
// check against the same `units_reserved` and both insert,
|
|
414
|
+
// overshooting `max_units_available` (oversell). The ATOMIC guard
|
|
415
|
+
// is the conditional increment further down: the cap is folded
|
|
416
|
+
// into the UPDATE's WHERE so the increment only applies when it
|
|
417
|
+
// stays within the cap, and SQLite serialises the two writers so
|
|
418
|
+
// exactly one wins.
|
|
372
419
|
if (campaign.max_units_available !== null && campaign.max_units_available !== undefined) {
|
|
373
420
|
if (campaign.units_reserved + input.quantity > campaign.max_units_available) {
|
|
374
421
|
throw new TypeError("preorder.reserve: would exceed max_units_available " +
|
|
@@ -378,23 +425,46 @@ function create(opts) {
|
|
|
378
425
|
}
|
|
379
426
|
|
|
380
427
|
var ts = _now();
|
|
428
|
+
// Claim capacity FIRST via a conditional increment that gates on
|
|
429
|
+
// the cap inside its own WHERE. rowCount 0 means the cap would be
|
|
430
|
+
// exceeded (or the campaign vanished) — refuse before writing any
|
|
431
|
+
// reservation row, so the counter never overshoots the cap under
|
|
432
|
+
// concurrency.
|
|
433
|
+
var claim = await query(
|
|
434
|
+
"UPDATE preorder_campaigns SET units_reserved = units_reserved + ?1, updated_at = ?2 " +
|
|
435
|
+
"WHERE slug = ?3 AND status = 'active' " +
|
|
436
|
+
"AND (max_units_available IS NULL OR units_reserved + ?1 <= max_units_available)",
|
|
437
|
+
[input.quantity, ts, input.campaign_slug],
|
|
438
|
+
);
|
|
439
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
440
|
+
throw new TypeError("preorder.reserve: would exceed max_units_available " +
|
|
441
|
+
"(cap=" + campaign.max_units_available + ", reserved=" + campaign.units_reserved +
|
|
442
|
+
", requested=" + input.quantity + ")");
|
|
443
|
+
}
|
|
381
444
|
// Pass the monotonic ts into uuid.v7 so the 48-bit timestamp
|
|
382
445
|
// prefix is strictly increasing across consecutive reservations
|
|
383
446
|
// — the (id) keyset cursor used by reservationsForCustomer is
|
|
384
447
|
// monotonic without depending on Date.now() ticking between
|
|
385
448
|
// calls.
|
|
386
449
|
var id = b.uuid.v7({ now: ts });
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
450
|
+
try {
|
|
451
|
+
await query(
|
|
452
|
+
"INSERT INTO preorder_reservations (id, campaign_slug, customer_id, quantity, " +
|
|
453
|
+
"payment_intent_id, status, reservation_at) " +
|
|
454
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6)",
|
|
455
|
+
[id, input.campaign_slug, input.customer_id, input.quantity, paymentIntentId, ts],
|
|
456
|
+
);
|
|
457
|
+
} catch (e) {
|
|
458
|
+
// The reservation insert failed after we claimed capacity —
|
|
459
|
+
// compensate by releasing the units we reserved so the counter
|
|
460
|
+
// doesn't strand capacity the customer never got.
|
|
461
|
+
await query(
|
|
462
|
+
"UPDATE preorder_campaigns SET units_reserved = MAX(0, units_reserved - ?1), " +
|
|
463
|
+
"updated_at = ?2 WHERE slug = ?3",
|
|
464
|
+
[input.quantity, _now(), input.campaign_slug],
|
|
465
|
+
);
|
|
466
|
+
throw e;
|
|
467
|
+
}
|
|
398
468
|
return await _getReservation(id);
|
|
399
469
|
},
|
|
400
470
|
|
|
@@ -447,11 +517,24 @@ function create(opts) {
|
|
|
447
517
|
", only active reservations can be cancelled");
|
|
448
518
|
}
|
|
449
519
|
var ts = _now();
|
|
450
|
-
|
|
520
|
+
// Claim the cancel atomically, gated on `status = 'active'`. The
|
|
521
|
+
// JS check above is advisory; this conditional UPDATE is the
|
|
522
|
+
// guard. Two concurrent cancels of the same reservation would
|
|
523
|
+
// otherwise both pass the JS check and both decrement the
|
|
524
|
+
// counter — double-freeing capacity. SQLite serialises writers,
|
|
525
|
+
// so exactly one UPDATE matches; the loser sees rowCount 0 and
|
|
526
|
+
// refuses WITHOUT decrementing, so the counter stays correct.
|
|
527
|
+
var claim = await query(
|
|
451
528
|
"UPDATE preorder_reservations SET status = 'cancelled', cancelled_at = ?1, " +
|
|
452
|
-
"cancel_reason = ?2 WHERE id = ?3",
|
|
529
|
+
"cancel_reason = ?2 WHERE id = ?3 AND status = 'active'",
|
|
453
530
|
[ts, reason, reservation.id],
|
|
454
531
|
);
|
|
532
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
533
|
+
throw new TypeError("preorder.cancelReservation: reservation " +
|
|
534
|
+
JSON.stringify(reservation.id) + " was already cancelled or converted concurrently");
|
|
535
|
+
}
|
|
536
|
+
// Only the winner of the claim frees capacity. MAX(0, …) clamps
|
|
537
|
+
// the counter against out-of-band corruption.
|
|
455
538
|
await query(
|
|
456
539
|
"UPDATE preorder_campaigns SET units_reserved = MAX(0, units_reserved - ?1), " +
|
|
457
540
|
"updated_at = ?2 WHERE slug = ?3",
|
|
@@ -484,10 +567,18 @@ function create(opts) {
|
|
|
484
567
|
JSON.stringify(reservation.campaign_slug) + " not found");
|
|
485
568
|
}
|
|
486
569
|
var ts = _now();
|
|
487
|
-
|
|
570
|
+
// `_convertOne` does the atomic claim internally. If a concurrent
|
|
571
|
+
// converter beat us to this reservation we lose the race and
|
|
572
|
+
// refuse — the JS `status === "active"` check above is advisory;
|
|
573
|
+
// the conditional UPDATE is the real guard.
|
|
574
|
+
var outcome = await _convertOne(reservation, campaign, ts);
|
|
575
|
+
if (!outcome.won) {
|
|
576
|
+
throw new TypeError("preorder.convertReservationToOrder: reservation " +
|
|
577
|
+
JSON.stringify(reservation.id) + " was already converted concurrently");
|
|
578
|
+
}
|
|
488
579
|
return {
|
|
489
580
|
reservation_id: reservation.id,
|
|
490
|
-
converted_order_id: orderId,
|
|
581
|
+
converted_order_id: outcome.orderId,
|
|
491
582
|
status: "converted",
|
|
492
583
|
};
|
|
493
584
|
},
|
|
@@ -516,22 +607,54 @@ function create(opts) {
|
|
|
516
607
|
throw new TypeError("preorder.launchCampaign: now (" + input.now +
|
|
517
608
|
") is before launch_at (" + campaign.launch_at + ")");
|
|
518
609
|
}
|
|
610
|
+
var ts = _now();
|
|
611
|
+
// Claim the campaign FIRST, gated on `status = 'active'`. The JS
|
|
612
|
+
// status check above is advisory; this conditional UPDATE is the
|
|
613
|
+
// atomic guard. SQLite serialises writers, so exactly one of two
|
|
614
|
+
// concurrent launches matches — the loser sees rowCount 0 and
|
|
615
|
+
// refuses, so the reservation-conversion sweep below runs at most
|
|
616
|
+
// once for the campaign. (Each individual reservation is ALSO
|
|
617
|
+
// claim-guarded inside `_convertOne`, so even a torn interleave
|
|
618
|
+
// can't double-mint an order.)
|
|
619
|
+
var claim = await query(
|
|
620
|
+
"UPDATE preorder_campaigns SET status = 'launched', launched_at = ?1, updated_at = ?1 " +
|
|
621
|
+
"WHERE slug = ?2 AND status = 'active'",
|
|
622
|
+
[ts, input.slug],
|
|
623
|
+
);
|
|
624
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
625
|
+
throw new TypeError("preorder.launchCampaign: campaign " +
|
|
626
|
+
JSON.stringify(input.slug) + " was already launched concurrently");
|
|
627
|
+
}
|
|
519
628
|
var activeRows = (await query(
|
|
520
629
|
"SELECT * FROM preorder_reservations WHERE campaign_slug = ?1 AND status = 'active' " +
|
|
521
630
|
"ORDER BY id ASC",
|
|
522
631
|
[input.slug],
|
|
523
632
|
)).rows;
|
|
524
|
-
var ts = _now();
|
|
525
633
|
var converted = [];
|
|
526
|
-
|
|
527
|
-
var
|
|
528
|
-
|
|
634
|
+
try {
|
|
635
|
+
for (var i = 0; i < activeRows.length; i += 1) {
|
|
636
|
+
var outcome = await _convertOne(activeRows[i], campaign, ts);
|
|
637
|
+
// A reservation cancelled/converted out-of-band between the
|
|
638
|
+
// SELECT and the per-row claim is skipped silently — it's no
|
|
639
|
+
// longer 'active', so it isn't ours to convert.
|
|
640
|
+
if (outcome.won) {
|
|
641
|
+
converted.push({ reservation_id: activeRows[i].id, converted_order_id: outcome.orderId });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
} catch (convErr) {
|
|
645
|
+
// A conversion threw mid-sweep. Revert the campaign claim back to
|
|
646
|
+
// 'active' so a retry can re-launch and pick up the reservations
|
|
647
|
+
// still left active (the ones already converted stay converted —
|
|
648
|
+
// _convertOne skips non-active rows on the retry). Without this
|
|
649
|
+
// the campaign is stranded 'launched' and the unconverted
|
|
650
|
+
// reservations can never be swept into orders.
|
|
651
|
+
await query(
|
|
652
|
+
"UPDATE preorder_campaigns SET status = 'active', launched_at = NULL, updated_at = ?1 " +
|
|
653
|
+
"WHERE slug = ?2 AND status = 'launched'",
|
|
654
|
+
[_now(), input.slug],
|
|
655
|
+
);
|
|
656
|
+
throw convErr;
|
|
529
657
|
}
|
|
530
|
-
await query(
|
|
531
|
-
"UPDATE preorder_campaigns SET status = 'launched', launched_at = ?1, updated_at = ?1 " +
|
|
532
|
-
"WHERE slug = ?2",
|
|
533
|
-
[ts, input.slug],
|
|
534
|
-
);
|
|
535
658
|
return {
|
|
536
659
|
slug: input.slug,
|
|
537
660
|
launched_at: ts,
|
package/lib/print-queue.js
CHANGED
|
@@ -437,12 +437,28 @@ function create(opts) {
|
|
|
437
437
|
JSON.stringify(input.station_id));
|
|
438
438
|
}
|
|
439
439
|
var ts = _now();
|
|
440
|
-
|
|
440
|
+
// The in_progress -> failed transition IS the atomic claim: gate
|
|
441
|
+
// it on `status = 'in_progress'` in the WHERE so two concurrent
|
|
442
|
+
// markFailed callers (e.g. an operator retry plus an automated
|
|
443
|
+
// jam-detector) can't both win the JS check above and both fire
|
|
444
|
+
// the retry INSERT below. SQLite serializes writers, so exactly
|
|
445
|
+
// one UPDATE flips the row; the loser sees rowCount === 0 and
|
|
446
|
+
// returns the already-failed state WITHOUT re-requeueing —
|
|
447
|
+
// otherwise a single failure could mint two duplicate queued
|
|
448
|
+
// jobs (double-print).
|
|
449
|
+
var upd = await query(
|
|
441
450
|
"UPDATE print_jobs SET status = 'failed', failed_at = ?1, fail_reason = ?2 " +
|
|
442
|
-
"WHERE id = ?3",
|
|
451
|
+
"WHERE id = ?3 AND status = 'in_progress'",
|
|
443
452
|
[ts, reason, jobId],
|
|
444
453
|
);
|
|
445
454
|
var failed = await _getRow(jobId);
|
|
455
|
+
if (Number(upd.rowCount || 0) !== 1) {
|
|
456
|
+
// Another caller already transitioned this job out of
|
|
457
|
+
// in_progress. Return its current state; do not re-insert a
|
|
458
|
+
// retry row (that's the exactly-once side-effect we're
|
|
459
|
+
// protecting).
|
|
460
|
+
return { failed: failed };
|
|
461
|
+
}
|
|
446
462
|
var out = { failed: failed };
|
|
447
463
|
|
|
448
464
|
if (retry) {
|
package/lib/purchase-orders.js
CHANGED
|
@@ -599,17 +599,103 @@ function create(opts) {
|
|
|
599
599
|
}
|
|
600
600
|
}
|
|
601
601
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
|
|
602
|
+
var ts = _now();
|
|
603
|
+
var priorStatus = current.status;
|
|
604
|
+
|
|
605
|
+
// Claim the receipt atomically by advancing the per-line
|
|
606
|
+
// counters BEFORE composing the inventoryReceive restock — the
|
|
607
|
+
// line counter is the true exactly-once gate here. A PO-level
|
|
608
|
+
// status claim can't serialize two concurrent receipts on its
|
|
609
|
+
// own: 'partially_received' is both a legal source AND a legal
|
|
610
|
+
// destination (a partial receipt that leaves the PO still
|
|
611
|
+
// partial is a status self-loop), so a status-only WHERE matches
|
|
612
|
+
// for both racers. Instead each line increment is guarded by the
|
|
613
|
+
// exact prior quantity the caller observed AND the ordered cap:
|
|
614
|
+
// SET quantity_received = quantity_received + addQ
|
|
615
|
+
// WHERE id = lineId
|
|
616
|
+
// AND quantity_received = observedPrior -- optimistic CAS
|
|
617
|
+
// AND quantity_received + addQ <= quantity_ordered -- cap
|
|
618
|
+
// Two concurrent receipts both observe prior=P; the winner's
|
|
619
|
+
// increment lands (P -> P+addQ), the loser's `= P` no longer
|
|
620
|
+
// matches (the row now reads P+addQ) so its rowCount is 0 — it
|
|
621
|
+
// refuses, and the restock + counter advance run for exactly one
|
|
622
|
+
// caller. The cap clause also makes over-receipt a hard refusal
|
|
623
|
+
// at write time, not just at the JS pre-flight above.
|
|
624
|
+
var claimed = [];
|
|
625
|
+
for (var c = 0; c < rxSkus.length; c += 1) {
|
|
626
|
+
var cLine = skuToLine[rxSkus[c]];
|
|
627
|
+
var cRxe = rxMap[rxSkus[c]];
|
|
628
|
+
var cPrior = Number(cLine.quantity_received || 0);
|
|
629
|
+
var cAdd = cRxe.quantity_received;
|
|
630
|
+
var lineClaim;
|
|
631
|
+
if (cRxe.unit_cost_minor != null) {
|
|
632
|
+
lineClaim = await query(
|
|
633
|
+
"UPDATE purchase_order_lines SET quantity_received = quantity_received + ?1, " +
|
|
634
|
+
"unit_cost_minor = ?2, updated_at = ?3 " +
|
|
635
|
+
"WHERE id = ?4 AND quantity_received = ?5 AND quantity_received + ?1 <= quantity_ordered",
|
|
636
|
+
[cAdd, cRxe.unit_cost_minor, ts, cLine.id, cPrior],
|
|
637
|
+
);
|
|
638
|
+
} else {
|
|
639
|
+
lineClaim = await query(
|
|
640
|
+
"UPDATE purchase_order_lines SET quantity_received = quantity_received + ?1, " +
|
|
641
|
+
"updated_at = ?2 " +
|
|
642
|
+
"WHERE id = ?3 AND quantity_received = ?4 AND quantity_received + ?1 <= quantity_ordered",
|
|
643
|
+
[cAdd, ts, cLine.id, cPrior],
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
if (Number(lineClaim.rowCount || 0) !== 1) {
|
|
647
|
+
// Lost the race on this line (a concurrent receipt already
|
|
648
|
+
// advanced it past the value we observed) — roll back the
|
|
649
|
+
// line claims we already won this call, self-targeting the
|
|
650
|
+
// exact amount each added so a concurrent winner isn't
|
|
651
|
+
// clobbered, then refuse. No restock has fired yet.
|
|
652
|
+
for (var rb = 0; rb < claimed.length; rb += 1) {
|
|
653
|
+
if (claimed[rb].costChanged) {
|
|
654
|
+
await query(
|
|
655
|
+
"UPDATE purchase_order_lines SET quantity_received = quantity_received - ?1, " +
|
|
656
|
+
"unit_cost_minor = ?2, updated_at = ?3 WHERE id = ?4 AND quantity_received >= ?1",
|
|
657
|
+
[claimed[rb].add, claimed[rb].priorCost, ts, claimed[rb].id],
|
|
658
|
+
);
|
|
659
|
+
} else {
|
|
660
|
+
await query(
|
|
661
|
+
"UPDATE purchase_order_lines SET quantity_received = quantity_received - ?1, updated_at = ?2 " +
|
|
662
|
+
"WHERE id = ?3 AND quantity_received >= ?1",
|
|
663
|
+
[claimed[rb].add, ts, claimed[rb].id],
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
var lost = new Error("purchase-orders.recordPartialReceipt: refused — PO " +
|
|
668
|
+
poId + " line for sku " + JSON.stringify(rxSkus[c]) +
|
|
669
|
+
" changed under us (receipt recorded by a concurrent call)");
|
|
670
|
+
lost.code = "PO_TRANSITION_REFUSED";
|
|
671
|
+
throw lost;
|
|
672
|
+
}
|
|
673
|
+
claimed.push({
|
|
674
|
+
id: cLine.id, add: cAdd,
|
|
675
|
+
// Capture whether this claim overwrote unit_cost_minor and its
|
|
676
|
+
// prior value, so a rollback can restore the cost — not just
|
|
677
|
+
// the quantity — when no stock ends up landing.
|
|
678
|
+
costChanged: cRxe.unit_cost_minor != null,
|
|
679
|
+
priorCost: cLine.unit_cost_minor,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// From here the line claims are held. Any throw past this point
|
|
684
|
+
// must release them (self-targeting subtraction) so the PO isn't
|
|
685
|
+
// stranded with advanced counters but no restock landed.
|
|
612
686
|
var receiptResult = null;
|
|
687
|
+
try {
|
|
688
|
+
|
|
689
|
+
// Compose inventoryReceive.draft + apply for the winner. If the
|
|
690
|
+
// catalog refuses (unknown SKU, decimal qty, etc.) the rollback
|
|
691
|
+
// below releases the line claims so the PO state stays
|
|
692
|
+
// consistent with the catalog — a half-applied receipt across
|
|
693
|
+
// two writeable surfaces is the worst-case for reconciliation.
|
|
694
|
+
// The receipt primitive's draft step requires a unique
|
|
695
|
+
// `reference`; when the caller didn't supply one, derive a
|
|
696
|
+
// deterministic-per-call value from the PO id + occurrence
|
|
697
|
+
// timestamp so two recordPartialReceipt calls on the same PO
|
|
698
|
+
// produce distinct receipt rows.
|
|
613
699
|
if (receiveHandle) {
|
|
614
700
|
var receiptRef = reference != null
|
|
615
701
|
? reference
|
|
@@ -645,33 +731,18 @@ function create(opts) {
|
|
|
645
731
|
};
|
|
646
732
|
}
|
|
647
733
|
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
"UPDATE purchase_order_lines SET quantity_received = ?1, " +
|
|
658
|
-
"unit_cost_minor = ?2, updated_at = ?3 WHERE id = ?4",
|
|
659
|
-
[newQty, rxe2.unit_cost_minor, ts, line2.id],
|
|
660
|
-
);
|
|
661
|
-
} else {
|
|
662
|
-
await query(
|
|
663
|
-
"UPDATE purchase_order_lines SET quantity_received = ?1, updated_at = ?2 WHERE id = ?3",
|
|
664
|
-
[newQty, ts, line2.id],
|
|
665
|
-
);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
// Recompute the PO-level status from the now-current line
|
|
670
|
-
// state. The status transition only flips forward (no
|
|
671
|
-
// 'received' -> 'partially_received' regression).
|
|
734
|
+
// The line counters were already advanced by the claim loop
|
|
735
|
+
// above (the SET quantity_received = quantity_received + addQ
|
|
736
|
+
// increment); no further per-line write is needed. Recompute the
|
|
737
|
+
// PO-level status from the now-current line state and flip it
|
|
738
|
+
// forward (any-received -> partially_received, every-line-full ->
|
|
739
|
+
// received). The status read is from the freshest line state so a
|
|
740
|
+
// concurrent winner that advanced a different line is reflected;
|
|
741
|
+
// the transition only moves forward (no received -> partial
|
|
742
|
+
// regression).
|
|
672
743
|
var updatedLines = await _getLinesRaw(poId);
|
|
673
|
-
var nextStatus = _statusAfterReceipt(updatedLines,
|
|
674
|
-
if (nextStatus !==
|
|
744
|
+
var nextStatus = _statusAfterReceipt(updatedLines, priorStatus);
|
|
745
|
+
if (nextStatus !== priorStatus) {
|
|
675
746
|
await query(
|
|
676
747
|
"UPDATE purchase_orders SET status = ?1, updated_at = ?2 WHERE id = ?3",
|
|
677
748
|
[nextStatus, ts, poId],
|
|
@@ -683,6 +754,30 @@ function create(opts) {
|
|
|
683
754
|
);
|
|
684
755
|
}
|
|
685
756
|
|
|
757
|
+
} catch (e) {
|
|
758
|
+
// A step after the line claims threw (catalog refusal). Release
|
|
759
|
+
// every line claim this call won, self-targeting the exact
|
|
760
|
+
// amount each added (guarded by `>= add` so a concurrent winner
|
|
761
|
+
// that advanced the line further isn't clobbered) so the PO
|
|
762
|
+
// isn't stranded with advanced counters but no restock landed.
|
|
763
|
+
for (var rk = 0; rk < claimed.length; rk += 1) {
|
|
764
|
+
if (claimed[rk].costChanged) {
|
|
765
|
+
await query(
|
|
766
|
+
"UPDATE purchase_order_lines SET quantity_received = quantity_received - ?1, " +
|
|
767
|
+
"unit_cost_minor = ?2, updated_at = ?3 WHERE id = ?4 AND quantity_received >= ?1",
|
|
768
|
+
[claimed[rk].add, claimed[rk].priorCost, ts, claimed[rk].id],
|
|
769
|
+
);
|
|
770
|
+
} else {
|
|
771
|
+
await query(
|
|
772
|
+
"UPDATE purchase_order_lines SET quantity_received = quantity_received - ?1, updated_at = ?2 " +
|
|
773
|
+
"WHERE id = ?3 AND quantity_received >= ?1",
|
|
774
|
+
[claimed[rk].add, ts, claimed[rk].id],
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
throw e;
|
|
779
|
+
}
|
|
780
|
+
|
|
686
781
|
var hydrated = await _hydrated(poId);
|
|
687
782
|
hydrated.receipt = receiptResult;
|
|
688
783
|
return hydrated;
|
package/lib/seller-signup.js
CHANGED
|
@@ -842,6 +842,29 @@ function create(opts) {
|
|
|
842
842
|
throw notReady;
|
|
843
843
|
}
|
|
844
844
|
|
|
845
|
+
// Atomic claim FIRST: flip in_review -> approved gated on the
|
|
846
|
+
// status. The JS checks above only refuse a sequential re-approve;
|
|
847
|
+
// two concurrent approvals would both read 'in_review', both pass,
|
|
848
|
+
// and both call registerVendor — which mints a fresh vendor row
|
|
849
|
+
// each time, leaving a DUPLICATE vendor for one application. The
|
|
850
|
+
// `AND status = 'in_review'` predicate lets exactly one caller win;
|
|
851
|
+
// the loser is refused before registering anything. vendor_slug is
|
|
852
|
+
// stamped in a second UPDATE after the (winner-only) registration.
|
|
853
|
+
var ts = _now();
|
|
854
|
+
var claim = await query(
|
|
855
|
+
"UPDATE seller_applications " +
|
|
856
|
+
"SET status = 'approved', approved_at = ?1, approved_by = ?2, updated_at = ?1 " +
|
|
857
|
+
"WHERE id = ?3 AND status = 'in_review'",
|
|
858
|
+
[ts, approvedBy, applicationId],
|
|
859
|
+
);
|
|
860
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
861
|
+
var raced = new Error(
|
|
862
|
+
"seller-signup.approveApplication: refused — application is no longer in_review (concurrent approval)"
|
|
863
|
+
);
|
|
864
|
+
raced.code = "APPLICATION_NOT_READY";
|
|
865
|
+
throw raced;
|
|
866
|
+
}
|
|
867
|
+
|
|
845
868
|
// Compose vendors.registerVendor — the operator-facing v1
|
|
846
869
|
// defaults a fresh marketplace seller to bank_transfer payout +
|
|
847
870
|
// 70% commission split; the operator tunes both via
|
|
@@ -860,6 +883,17 @@ function create(opts) {
|
|
|
860
883
|
status: "active",
|
|
861
884
|
});
|
|
862
885
|
} catch (e) {
|
|
886
|
+
// Roll the claim back so the application returns to in_review and
|
|
887
|
+
// can be re-approved — the winning claim must not strand the app
|
|
888
|
+
// in 'approved' with no vendor behind it.
|
|
889
|
+
try {
|
|
890
|
+
await query(
|
|
891
|
+
"UPDATE seller_applications " +
|
|
892
|
+
"SET status = 'in_review', approved_at = NULL, approved_by = NULL, updated_at = ?1 " +
|
|
893
|
+
"WHERE id = ?2 AND status = 'approved'",
|
|
894
|
+
[_now(), applicationId],
|
|
895
|
+
);
|
|
896
|
+
} catch (_e2) { /* drop-silent — the registerVendor error is the caller's signal */ }
|
|
863
897
|
// Surface the vendors error with our typed-code wrapper so
|
|
864
898
|
// operators can distinguish a vendors-layer refusal from an
|
|
865
899
|
// application-layer refusal.
|
|
@@ -872,13 +906,10 @@ function create(opts) {
|
|
|
872
906
|
throw wrapped;
|
|
873
907
|
}
|
|
874
908
|
|
|
875
|
-
|
|
909
|
+
// Stamp the registered vendor slug onto the now-approved application.
|
|
876
910
|
await query(
|
|
877
|
-
"UPDATE seller_applications "
|
|
878
|
-
|
|
879
|
-
" vendor_slug = ?3, updated_at = ?1 " +
|
|
880
|
-
"WHERE id = ?4",
|
|
881
|
-
[ts, approvedBy, vendor.slug, applicationId],
|
|
911
|
+
"UPDATE seller_applications SET vendor_slug = ?1, updated_at = ?2 WHERE id = ?3",
|
|
912
|
+
[vendor.slug, _now(), applicationId],
|
|
882
913
|
);
|
|
883
914
|
|
|
884
915
|
return {
|
package/lib/split-shipments.js
CHANGED
|
@@ -603,26 +603,67 @@ function create(opts) {
|
|
|
603
603
|
}
|
|
604
604
|
|
|
605
605
|
var hydrated = _hydratePlan(row);
|
|
606
|
+
var ts = _now();
|
|
607
|
+
// Atomic claim BEFORE any shipment is created. The JS status
|
|
608
|
+
// check above is racy: two concurrent callers both read a
|
|
609
|
+
// `proposed` row and both fall through to createShipment,
|
|
610
|
+
// double-creating shipments. The conditional UPDATE is the real
|
|
611
|
+
// gate — SQLite serializes writers, so exactly one caller flips
|
|
612
|
+
// proposed → executed and gets rowCount === 1; the loser sees 0
|
|
613
|
+
// and refuses without touching orderTracking.
|
|
614
|
+
var claim = await query(
|
|
615
|
+
"UPDATE split_shipment_plans SET status = 'executed', executed_at = ?1 " +
|
|
616
|
+
"WHERE id = ?2 AND status = 'proposed'",
|
|
617
|
+
[ts, input.plan.id],
|
|
618
|
+
);
|
|
619
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
620
|
+
// Lost the race (or the row was concurrently cancelled/executed
|
|
621
|
+
// between the read above and the claim). Re-read so the message
|
|
622
|
+
// reflects the row's actual current status.
|
|
623
|
+
var lost = await _getPlanRow(input.plan.id);
|
|
624
|
+
throw new TypeError("split-shipments.executeSplit: plan " + input.plan.id +
|
|
625
|
+
" is " + ((lost && lost.status) || "gone") +
|
|
626
|
+
", only proposed plans can be executed");
|
|
627
|
+
}
|
|
628
|
+
|
|
606
629
|
var shipmentIds = [];
|
|
607
|
-
|
|
608
|
-
var
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
630
|
+
try {
|
|
631
|
+
for (var i = 0; i < hydrated.shipments.length; i += 1) {
|
|
632
|
+
var parcel = hydrated.shipments[i];
|
|
633
|
+
var notes = "split:" + parcel.rationale + " (" + (i + 1) + "/" + hydrated.shipments.length + ")";
|
|
634
|
+
var createInput = {
|
|
635
|
+
order_id: input.order_id,
|
|
636
|
+
carrier: carrier,
|
|
637
|
+
notes: notes,
|
|
638
|
+
};
|
|
639
|
+
if (carrier === "other") {
|
|
640
|
+
createInput.carrier_other_name = carrierOtherName;
|
|
641
|
+
}
|
|
642
|
+
var s = await orderTracking.createShipment(createInput);
|
|
643
|
+
shipmentIds.push(s.id);
|
|
617
644
|
}
|
|
618
|
-
|
|
619
|
-
|
|
645
|
+
} catch (e) {
|
|
646
|
+
// A createShipment throw after the claim would strand the plan
|
|
647
|
+
// in `executed` with a partial shipment set. Revert the claim
|
|
648
|
+
// (self-targeting WHERE so it only fires on the row WE claimed)
|
|
649
|
+
// and rethrow so the operator can retry once the underlying
|
|
650
|
+
// failure clears. Any shipment rows already written by earlier
|
|
651
|
+
// parcels are reconciled via the orderTracking primitive's own
|
|
652
|
+
// per-shipment flow — the plan returns to `proposed`.
|
|
653
|
+
await query(
|
|
654
|
+
"UPDATE split_shipment_plans SET status = 'proposed', executed_at = NULL " +
|
|
655
|
+
"WHERE id = ?1 AND status = 'executed'",
|
|
656
|
+
[input.plan.id],
|
|
657
|
+
);
|
|
658
|
+
throw e;
|
|
620
659
|
}
|
|
621
|
-
|
|
660
|
+
|
|
661
|
+
// Second UPDATE persists the shipment-id list now that every
|
|
662
|
+
// parcel landed. Self-targeting WHERE id keyed off the row we
|
|
663
|
+
// already claimed — the status gate was the first UPDATE.
|
|
622
664
|
await query(
|
|
623
|
-
"UPDATE split_shipment_plans SET
|
|
624
|
-
|
|
625
|
-
[JSON.stringify(shipmentIds), ts, input.plan.id],
|
|
665
|
+
"UPDATE split_shipment_plans SET executed_shipment_ids_json = ?1 WHERE id = ?2",
|
|
666
|
+
[JSON.stringify(shipmentIds), input.plan.id],
|
|
626
667
|
);
|
|
627
668
|
return _hydratePlan(await _getPlanRow(input.plan.id));
|
|
628
669
|
},
|