@blamejs/blamejs-shop 0.0.66 → 0.0.72
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 +12 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +36 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/loyalty-earn-rules.js +786 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/pixel-events.js +995 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/split-shipments.js +7 -1
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -165,4 +165,40 @@ module.exports = {
|
|
|
165
165
|
creditLimits: require("./credit-limits"),
|
|
166
166
|
themeAssets: require("./theme-assets"),
|
|
167
167
|
businessHours: require("./business-hours"),
|
|
168
|
+
customerNotes: require("./customer-notes"),
|
|
169
|
+
orderTags: require("./order-tags"),
|
|
170
|
+
customerRiskProfile: require("./customer-risk-profile"),
|
|
171
|
+
tierBenefits: require("./tier-benefits"),
|
|
172
|
+
promoBundles: require("./promo-bundles"),
|
|
173
|
+
subscriptionGifts: require("./subscription-gifts"),
|
|
174
|
+
reorderReminders: require("./reorder-reminders"),
|
|
175
|
+
productQA: require("./product-qa"),
|
|
176
|
+
localeRouter: require("./locale-router"),
|
|
177
|
+
inventoryWriteoffs: require("./inventory-writeoffs"),
|
|
178
|
+
robotsConfig: require("./robots-config"),
|
|
179
|
+
refundAutomation: require("./refund-automation"),
|
|
180
|
+
sellerSignup: require("./seller-signup"),
|
|
181
|
+
taxCertRenewals: require("./tax-cert-renewals"),
|
|
182
|
+
orderEscalation: require("./order-escalation"),
|
|
183
|
+
orderRatings: require("./order-ratings"),
|
|
184
|
+
wishlistSharing: require("./wishlist-sharing"),
|
|
185
|
+
inventoryAllocations: require("./inventory-allocations"),
|
|
186
|
+
dropshipForwarding: require("./dropship-forwarding"),
|
|
187
|
+
damagePhotos: require("./damage-photos"),
|
|
188
|
+
pushNotifications: require("./push-notifications"),
|
|
189
|
+
autoReplenish: require("./auto-replenish"),
|
|
190
|
+
operatorRoles: require("./operator-roles"),
|
|
191
|
+
clickstream: require("./clickstream"),
|
|
192
|
+
customerActivity: require("./customer-activity"),
|
|
193
|
+
packingSlips: require("./packing-slips"),
|
|
194
|
+
printQueue: require("./print-queue"),
|
|
195
|
+
wishlistAlerts: require("./wishlist-alerts"),
|
|
196
|
+
assemblyInstructions: require("./assembly-instructions"),
|
|
197
|
+
clickAndCollect: require("./click-and-collect"),
|
|
198
|
+
customerSurveys: require("./customer-surveys"),
|
|
199
|
+
emailTemplates: require("./email-templates"),
|
|
200
|
+
knowledgeBase: require("./knowledge-base"),
|
|
201
|
+
pixelEvents: require("./pixel-events"),
|
|
202
|
+
sitemapGenerator: require("./sitemap-generator"),
|
|
203
|
+
loyaltyEarnRules: require("./loyalty-earn-rules"),
|
|
168
204
|
};
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.inventoryAllocations
|
|
4
|
+
* @title Inventory allocations — soft-reserve holds for in-progress carts
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* `inventoryLocations` owns the committed shelf — every row in
|
|
8
|
+
* `inventory_stock` is real, claimable inventory. The gap that
|
|
9
|
+
* opens during checkout is the in-progress cart: the shopper has
|
|
10
|
+
* selected N units of a SKU but hasn't paid yet. Without a holds
|
|
11
|
+
* layer, a second shopper hitting "add to cart" a moment later
|
|
12
|
+
* sees the same N units available, and one of the two has to be
|
|
13
|
+
* told at payment time the item is gone — the classic oversell.
|
|
14
|
+
*
|
|
15
|
+
* This primitive is the holds layer. Each `holdForCart` call
|
|
16
|
+
* reserves quantity against (sku, location_code?) for a specific
|
|
17
|
+
* cart_id with a TTL. Reads that need "how many can I actually
|
|
18
|
+
* sell?" subtract the open-hold sum from the committed shelf via
|
|
19
|
+
* `availableForSku`. On checkout completion the hold converts to
|
|
20
|
+
* a real stock movement via `inventoryLocations.adjustStock` —
|
|
21
|
+
* this primitive never writes `inventory_stock` directly; it
|
|
22
|
+
* composes the locations primitive for every committed mutation.
|
|
23
|
+
*
|
|
24
|
+
* Verbs:
|
|
25
|
+
* holdForCart — open a hold for { cart_id, sku, variant_id?,
|
|
26
|
+
* quantity, location_code?, ttl_seconds? }.
|
|
27
|
+
* Refuses if the requested qty would drive
|
|
28
|
+
* availableForSku below zero.
|
|
29
|
+
* releaseHold — flip a single hold to `released` with an
|
|
30
|
+
* operator-supplied reason.
|
|
31
|
+
* releaseAllForCart — bulk-release every active hold for a cart
|
|
32
|
+
* (the abandonment / cancel path).
|
|
33
|
+
* commitHold — finalize the hold: composes
|
|
34
|
+
* inventoryLocations.adjustStock to debit
|
|
35
|
+
* the committed shelf, then flips the hold
|
|
36
|
+
* to `committed` with the order_id.
|
|
37
|
+
* extendHold — bump expires_at by additional TTL seconds
|
|
38
|
+
* (the shopper went idle but is still on
|
|
39
|
+
* the page).
|
|
40
|
+
* availableForSku — committed total minus open-hold sum for
|
|
41
|
+
* the SKU (optionally filtered by location).
|
|
42
|
+
* holdsForCart — every hold (any status) for a cart_id —
|
|
43
|
+
* audit + UI surface.
|
|
44
|
+
* cleanupExpiredHolds — walks rows with expires_at <= now and
|
|
45
|
+
* status='held', flips them to `expired`.
|
|
46
|
+
* Idempotent.
|
|
47
|
+
* metricsForSku — aggregate hold activity for a SKU in a
|
|
48
|
+
* time window: counts by status, total qty
|
|
49
|
+
* held / released / committed / expired.
|
|
50
|
+
*
|
|
51
|
+
* Composition:
|
|
52
|
+
* - inventoryLocations — required. The holds layer composes its
|
|
53
|
+
* `adjustStock`, `stockForSku`, and
|
|
54
|
+
* `getLocation` verbs. Refusing to wire
|
|
55
|
+
* one through fails loud at boot.
|
|
56
|
+
* - b.uuid.v7 — hold-row PKs (sortable by insertion).
|
|
57
|
+
*
|
|
58
|
+
* Three-tier input validation (use the discipline; don't write
|
|
59
|
+
* the labels): every public verb here is a defensive request-
|
|
60
|
+
* shape reader OR a config-time entry point. Both throw on bad
|
|
61
|
+
* input. No drop-silent hot-path sinks.
|
|
62
|
+
*
|
|
63
|
+
* Monotonic clock: a per-factory monotonic timestamp ensures
|
|
64
|
+
* two holds opened against the same SKU in the same wall-clock
|
|
65
|
+
* millisecond carry strictly-increasing created_at values so
|
|
66
|
+
* the audit-by-time queries always order them deterministically.
|
|
67
|
+
* Forward-leaps when the wall clock outpaces the counter;
|
|
68
|
+
* otherwise bumps by 1ms.
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
var bShop;
|
|
72
|
+
function _b() {
|
|
73
|
+
if (!bShop) bShop = require("./index");
|
|
74
|
+
return bShop.framework;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- constants ----------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
var CART_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
80
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
81
|
+
var VARIANT_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
82
|
+
var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
83
|
+
var ORDER_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
84
|
+
var HOLD_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
85
|
+
var REASON_RE = /^[\S\s]{1,256}$/;
|
|
86
|
+
var DEFAULT_TTL = 900; // 15 minutes by default
|
|
87
|
+
var MAX_TTL = 24 * 60 * 60; // 24h ceiling — anything longer is a usage-error smell
|
|
88
|
+
var MIN_TTL = 1;
|
|
89
|
+
|
|
90
|
+
// ---- validators ---------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
function _cartId(s) {
|
|
93
|
+
if (typeof s !== "string" || !CART_ID_RE.test(s)) {
|
|
94
|
+
throw new TypeError("inventory-allocations: cart_id must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function _sku(s) {
|
|
98
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
99
|
+
throw new TypeError("inventory-allocations: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function _variantId(s) {
|
|
103
|
+
if (s == null) return null;
|
|
104
|
+
if (typeof s !== "string" || !VARIANT_ID_RE.test(s)) {
|
|
105
|
+
throw new TypeError("inventory-allocations: variant_id must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars) or be omitted");
|
|
106
|
+
}
|
|
107
|
+
return s;
|
|
108
|
+
}
|
|
109
|
+
function _locationCode(s) {
|
|
110
|
+
if (s == null) return null;
|
|
111
|
+
if (typeof s !== "string" || !CODE_RE.test(s)) {
|
|
112
|
+
throw new TypeError("inventory-allocations: location_code must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars) or be omitted");
|
|
113
|
+
}
|
|
114
|
+
return s;
|
|
115
|
+
}
|
|
116
|
+
function _orderId(s) {
|
|
117
|
+
if (typeof s !== "string" || !ORDER_ID_RE.test(s)) {
|
|
118
|
+
throw new TypeError("inventory-allocations: order_id must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function _holdId(s) {
|
|
122
|
+
if (typeof s !== "string" || !HOLD_ID_RE.test(s)) {
|
|
123
|
+
throw new TypeError("inventory-allocations: hold_id must be a UUID");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function _positiveInt(n, label) {
|
|
127
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
128
|
+
throw new TypeError("inventory-allocations: " + label + " must be a positive integer");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function _nonNegInt(n, label) {
|
|
132
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
133
|
+
throw new TypeError("inventory-allocations: " + label + " must be a non-negative integer");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function _ttlSeconds(n, label) {
|
|
137
|
+
if (!Number.isInteger(n) || n < MIN_TTL || n > MAX_TTL) {
|
|
138
|
+
throw new TypeError("inventory-allocations: " + (label || "ttl_seconds") +
|
|
139
|
+
" must be an integer in " + MIN_TTL + ".." + MAX_TTL + " (got " + JSON.stringify(n) + ")");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function _reason(s, label) {
|
|
143
|
+
if (typeof s !== "string" || !REASON_RE.test(s) || s.length > 256) {
|
|
144
|
+
throw new TypeError("inventory-allocations: " + (label || "reason") +
|
|
145
|
+
" must be a non-empty string ≤ 256 chars");
|
|
146
|
+
}
|
|
147
|
+
return s;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function _now() { return Date.now(); }
|
|
151
|
+
|
|
152
|
+
// ---- row hydration ------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
function _shapeHold(row) {
|
|
155
|
+
if (!row) return null;
|
|
156
|
+
return {
|
|
157
|
+
id: row.id,
|
|
158
|
+
cart_id: row.cart_id,
|
|
159
|
+
sku: row.sku,
|
|
160
|
+
variant_id: row.variant_id == null ? null : row.variant_id,
|
|
161
|
+
location_code: row.location_code == null ? null : row.location_code,
|
|
162
|
+
quantity: Number(row.quantity),
|
|
163
|
+
status: row.status,
|
|
164
|
+
ttl_seconds: Number(row.ttl_seconds),
|
|
165
|
+
expires_at: Number(row.expires_at),
|
|
166
|
+
committed_order_id: row.committed_order_id == null ? null : row.committed_order_id,
|
|
167
|
+
release_reason: row.release_reason == null ? null : row.release_reason,
|
|
168
|
+
created_at: Number(row.created_at),
|
|
169
|
+
released_at: row.released_at == null ? null : Number(row.released_at),
|
|
170
|
+
committed_at: row.committed_at == null ? null : Number(row.committed_at),
|
|
171
|
+
expired_at: row.expired_at == null ? null : Number(row.expired_at),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---- factory ------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
function create(opts) {
|
|
178
|
+
opts = opts || {};
|
|
179
|
+
// The inventoryLocations primitive owns every commit-time mutation
|
|
180
|
+
// on `inventory_stock` — the holds layer composes its adjustStock
|
|
181
|
+
// / stockForSku / getLocation verbs. Refusing to wire one through
|
|
182
|
+
// fails loud at boot rather than at first call.
|
|
183
|
+
if (!opts.inventoryLocations ||
|
|
184
|
+
typeof opts.inventoryLocations.adjustStock !== "function" ||
|
|
185
|
+
typeof opts.inventoryLocations.stockForSku !== "function" ||
|
|
186
|
+
typeof opts.inventoryLocations.getLocation !== "function") {
|
|
187
|
+
throw new TypeError("inventory-allocations.create: opts.inventoryLocations with " +
|
|
188
|
+
"adjustStock + stockForSku + getLocation is required");
|
|
189
|
+
}
|
|
190
|
+
var locations = opts.inventoryLocations;
|
|
191
|
+
var query = opts.query;
|
|
192
|
+
if (!query) {
|
|
193
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Per-factory monotonic clock. Two holds opened against the same
|
|
197
|
+
// SKU in the same wall-clock millisecond would otherwise tie on
|
|
198
|
+
// created_at and make audit-by-time queries non-deterministic.
|
|
199
|
+
// Forward-leap when the wall clock outpaces the counter; otherwise
|
|
200
|
+
// bump by 1ms so the sequence is strictly increasing per factory
|
|
201
|
+
// instance.
|
|
202
|
+
var _lastEventTs = 0;
|
|
203
|
+
function _monotonicTs() {
|
|
204
|
+
var wall = _now();
|
|
205
|
+
if (wall > _lastEventTs) _lastEventTs = wall;
|
|
206
|
+
else _lastEventTs += 1;
|
|
207
|
+
return _lastEventTs;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function _getHoldRow(holdId) {
|
|
211
|
+
var r = await query("SELECT * FROM inventory_holds WHERE id = ?1", [holdId]);
|
|
212
|
+
return r.rows[0] || null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Sum the qty of held rows for a SKU, optionally restricted to a
|
|
216
|
+
// single location. Holds with NULL location_code count against
|
|
217
|
+
// every location query (they're not pinned yet), matching the
|
|
218
|
+
// pessimistic-safe rule: an un-pinned hold should subtract from
|
|
219
|
+
// the answer to "can I sell at any location?"
|
|
220
|
+
async function _sumHeldQty(sku, locationCode) {
|
|
221
|
+
var sql, params;
|
|
222
|
+
if (locationCode == null) {
|
|
223
|
+
sql = "SELECT COALESCE(SUM(quantity), 0) AS total " +
|
|
224
|
+
"FROM inventory_holds WHERE sku = ?1 AND status = 'held'";
|
|
225
|
+
params = [sku];
|
|
226
|
+
} else {
|
|
227
|
+
sql = "SELECT COALESCE(SUM(quantity), 0) AS total " +
|
|
228
|
+
"FROM inventory_holds WHERE sku = ?1 AND status = 'held' " +
|
|
229
|
+
"AND (location_code = ?2 OR location_code IS NULL)";
|
|
230
|
+
params = [sku, locationCode];
|
|
231
|
+
}
|
|
232
|
+
var r = await query(sql, params);
|
|
233
|
+
return r.rows[0] ? Number(r.rows[0].total) : 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- holdForCart -----------------------------------------------------
|
|
237
|
+
|
|
238
|
+
async function holdForCart(input) {
|
|
239
|
+
if (!input || typeof input !== "object") {
|
|
240
|
+
throw new TypeError("inventory-allocations.holdForCart: input object required");
|
|
241
|
+
}
|
|
242
|
+
_cartId(input.cart_id);
|
|
243
|
+
_sku(input.sku);
|
|
244
|
+
var variantId = _variantId(input.variant_id);
|
|
245
|
+
var locationCode = _locationCode(input.location_code);
|
|
246
|
+
_positiveInt(input.quantity, "quantity");
|
|
247
|
+
var ttlSeconds = input.ttl_seconds == null ? DEFAULT_TTL : input.ttl_seconds;
|
|
248
|
+
_ttlSeconds(ttlSeconds, "ttl_seconds");
|
|
249
|
+
|
|
250
|
+
if (locationCode != null) {
|
|
251
|
+
var loc = await locations.getLocation(locationCode);
|
|
252
|
+
if (!loc) {
|
|
253
|
+
throw new TypeError("inventory-allocations.holdForCart: location_code " +
|
|
254
|
+
JSON.stringify(locationCode) + " not found");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Refuse if the new hold would drive availableForSku below zero.
|
|
259
|
+
// The shelf snapshot + the current held sum are read in sequence
|
|
260
|
+
// — at this layer the primitive accepts a small race window
|
|
261
|
+
// (two concurrent factories could both see headroom and both
|
|
262
|
+
// commit). Operators that need strict serializability run the
|
|
263
|
+
// hold path under a per-SKU advisory lock at the request layer;
|
|
264
|
+
// this primitive's contract is "best-effort soft reserve."
|
|
265
|
+
var shelf = await locations.stockForSku(input.sku);
|
|
266
|
+
var committed;
|
|
267
|
+
if (locationCode == null) {
|
|
268
|
+
committed = shelf.total;
|
|
269
|
+
} else {
|
|
270
|
+
committed = 0;
|
|
271
|
+
for (var i = 0; i < shelf.by_location.length; i += 1) {
|
|
272
|
+
if (shelf.by_location[i].code === locationCode) {
|
|
273
|
+
committed = shelf.by_location[i].quantity;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
var alreadyHeld = await _sumHeldQty(input.sku, locationCode);
|
|
279
|
+
var available = committed - alreadyHeld;
|
|
280
|
+
if (input.quantity > available) {
|
|
281
|
+
throw new TypeError("inventory-allocations.holdForCart: insufficient available stock for " +
|
|
282
|
+
input.sku + " (need " + input.quantity + ", available " + available +
|
|
283
|
+
" = committed " + committed + " - held " + alreadyHeld + ")");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
var ts = _monotonicTs();
|
|
287
|
+
var expiresAt = ts + ttlSeconds * 1000;
|
|
288
|
+
var id = _b().uuid.v7();
|
|
289
|
+
await query(
|
|
290
|
+
"INSERT INTO inventory_holds (id, cart_id, sku, variant_id, location_code, quantity, " +
|
|
291
|
+
"status, ttl_seconds, expires_at, created_at) " +
|
|
292
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'held', ?7, ?8, ?9)",
|
|
293
|
+
[id, input.cart_id, input.sku, variantId, locationCode, input.quantity,
|
|
294
|
+
ttlSeconds, expiresAt, ts],
|
|
295
|
+
);
|
|
296
|
+
return _shapeHold(await _getHoldRow(id));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---- releaseHold -----------------------------------------------------
|
|
300
|
+
|
|
301
|
+
async function releaseHold(input) {
|
|
302
|
+
if (!input || typeof input !== "object") {
|
|
303
|
+
throw new TypeError("inventory-allocations.releaseHold: input object required");
|
|
304
|
+
}
|
|
305
|
+
_holdId(input.hold_id);
|
|
306
|
+
var reason = _reason(input.reason, "reason");
|
|
307
|
+
|
|
308
|
+
var existing = await _getHoldRow(input.hold_id);
|
|
309
|
+
if (!existing) {
|
|
310
|
+
throw new TypeError("inventory-allocations.releaseHold: hold_id " +
|
|
311
|
+
JSON.stringify(input.hold_id) + " not found");
|
|
312
|
+
}
|
|
313
|
+
if (existing.status !== "held") {
|
|
314
|
+
throw new TypeError("inventory-allocations.releaseHold: hold is " + existing.status +
|
|
315
|
+
", expected 'held'");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
var ts = _monotonicTs();
|
|
319
|
+
await query(
|
|
320
|
+
"UPDATE inventory_holds SET status = 'released', released_at = ?1, release_reason = ?2 " +
|
|
321
|
+
"WHERE id = ?3 AND status = 'held'",
|
|
322
|
+
[ts, reason, input.hold_id],
|
|
323
|
+
);
|
|
324
|
+
return _shapeHold(await _getHoldRow(input.hold_id));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---- releaseAllForCart -----------------------------------------------
|
|
328
|
+
|
|
329
|
+
async function releaseAllForCart(input) {
|
|
330
|
+
if (!input || typeof input !== "object") {
|
|
331
|
+
throw new TypeError("inventory-allocations.releaseAllForCart: input object required");
|
|
332
|
+
}
|
|
333
|
+
_cartId(input.cart_id);
|
|
334
|
+
var reason = _reason(input.reason, "reason");
|
|
335
|
+
|
|
336
|
+
var ts = _monotonicTs();
|
|
337
|
+
var r = await query(
|
|
338
|
+
"UPDATE inventory_holds SET status = 'released', released_at = ?1, release_reason = ?2 " +
|
|
339
|
+
"WHERE cart_id = ?3 AND status = 'held'",
|
|
340
|
+
[ts, reason, input.cart_id],
|
|
341
|
+
);
|
|
342
|
+
return { cart_id: input.cart_id, released_count: r.rowCount };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---- commitHold ------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
async function commitHold(input) {
|
|
348
|
+
if (!input || typeof input !== "object") {
|
|
349
|
+
throw new TypeError("inventory-allocations.commitHold: input object required");
|
|
350
|
+
}
|
|
351
|
+
_holdId(input.hold_id);
|
|
352
|
+
_orderId(input.order_id);
|
|
353
|
+
|
|
354
|
+
var existing = await _getHoldRow(input.hold_id);
|
|
355
|
+
if (!existing) {
|
|
356
|
+
throw new TypeError("inventory-allocations.commitHold: hold_id " +
|
|
357
|
+
JSON.stringify(input.hold_id) + " not found");
|
|
358
|
+
}
|
|
359
|
+
if (existing.status !== "held") {
|
|
360
|
+
throw new TypeError("inventory-allocations.commitHold: hold is " + existing.status +
|
|
361
|
+
", expected 'held'");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// commitHold needs a concrete location to debit the shelf. A
|
|
365
|
+
// hold opened with location_code=NULL must be pinned before
|
|
366
|
+
// commit — the storefront / checkout flow decides which
|
|
367
|
+
// location ships and re-opens a pinned hold (or the operator
|
|
368
|
+
// adds a default-location override at the request layer).
|
|
369
|
+
if (existing.location_code == null) {
|
|
370
|
+
throw new TypeError("inventory-allocations.commitHold: hold " + existing.id +
|
|
371
|
+
" has no location_code — pin a location before commit");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Debit the committed shelf through inventoryLocations.adjustStock
|
|
375
|
+
// FIRST. If the shelf write fails (insufficient stock, unknown
|
|
376
|
+
// location), the hold row stays in 'held' status so a retry or
|
|
377
|
+
// a manual release decides next. Only after the debit lands does
|
|
378
|
+
// the hold flip to 'committed' — the audit trail then proves
|
|
379
|
+
// the commit happened.
|
|
380
|
+
await locations.adjustStock({
|
|
381
|
+
sku: existing.sku,
|
|
382
|
+
location_code: existing.location_code,
|
|
383
|
+
delta: -existing.quantity,
|
|
384
|
+
reason: "hold-commit:" + existing.id + " order:" + input.order_id,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
var ts = _monotonicTs();
|
|
388
|
+
await query(
|
|
389
|
+
"UPDATE inventory_holds SET status = 'committed', committed_at = ?1, " +
|
|
390
|
+
"committed_order_id = ?2 WHERE id = ?3 AND status = 'held'",
|
|
391
|
+
[ts, input.order_id, input.hold_id],
|
|
392
|
+
);
|
|
393
|
+
return _shapeHold(await _getHoldRow(input.hold_id));
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ---- extendHold ------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
async function extendHold(input) {
|
|
399
|
+
if (!input || typeof input !== "object") {
|
|
400
|
+
throw new TypeError("inventory-allocations.extendHold: input object required");
|
|
401
|
+
}
|
|
402
|
+
_holdId(input.hold_id);
|
|
403
|
+
_positiveInt(input.additional_ttl_seconds, "additional_ttl_seconds");
|
|
404
|
+
if (input.additional_ttl_seconds > MAX_TTL) {
|
|
405
|
+
throw new TypeError("inventory-allocations.extendHold: additional_ttl_seconds must be ≤ " + MAX_TTL);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
var existing = await _getHoldRow(input.hold_id);
|
|
409
|
+
if (!existing) {
|
|
410
|
+
throw new TypeError("inventory-allocations.extendHold: hold_id " +
|
|
411
|
+
JSON.stringify(input.hold_id) + " not found");
|
|
412
|
+
}
|
|
413
|
+
if (existing.status !== "held") {
|
|
414
|
+
throw new TypeError("inventory-allocations.extendHold: hold is " + existing.status +
|
|
415
|
+
", expected 'held'");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
var nextExpires = Number(existing.expires_at) + input.additional_ttl_seconds * 1000;
|
|
419
|
+
await query(
|
|
420
|
+
"UPDATE inventory_holds SET expires_at = ?1 WHERE id = ?2 AND status = 'held'",
|
|
421
|
+
[nextExpires, input.hold_id],
|
|
422
|
+
);
|
|
423
|
+
return _shapeHold(await _getHoldRow(input.hold_id));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---- availableForSku -------------------------------------------------
|
|
427
|
+
|
|
428
|
+
async function availableForSku(input) {
|
|
429
|
+
if (!input || typeof input !== "object") {
|
|
430
|
+
throw new TypeError("inventory-allocations.availableForSku: input object required");
|
|
431
|
+
}
|
|
432
|
+
_sku(input.sku);
|
|
433
|
+
var locationCode = _locationCode(input.location_code);
|
|
434
|
+
|
|
435
|
+
var shelf = await locations.stockForSku(input.sku);
|
|
436
|
+
var committed;
|
|
437
|
+
if (locationCode == null) {
|
|
438
|
+
committed = shelf.total;
|
|
439
|
+
} else {
|
|
440
|
+
committed = 0;
|
|
441
|
+
for (var i = 0; i < shelf.by_location.length; i += 1) {
|
|
442
|
+
if (shelf.by_location[i].code === locationCode) {
|
|
443
|
+
committed = shelf.by_location[i].quantity;
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
var held = await _sumHeldQty(input.sku, locationCode);
|
|
449
|
+
var available = committed - held;
|
|
450
|
+
if (available < 0) available = 0;
|
|
451
|
+
return {
|
|
452
|
+
sku: input.sku,
|
|
453
|
+
location_code: locationCode,
|
|
454
|
+
committed: committed,
|
|
455
|
+
on_hold: held,
|
|
456
|
+
available: available,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---- holdsForCart ----------------------------------------------------
|
|
461
|
+
|
|
462
|
+
async function holdsForCart(cartId) {
|
|
463
|
+
_cartId(cartId);
|
|
464
|
+
var r = await query(
|
|
465
|
+
"SELECT * FROM inventory_holds WHERE cart_id = ?1 ORDER BY created_at ASC",
|
|
466
|
+
[cartId],
|
|
467
|
+
);
|
|
468
|
+
return r.rows.map(_shapeHold);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ---- cleanupExpiredHolds ---------------------------------------------
|
|
472
|
+
|
|
473
|
+
async function cleanupExpiredHolds(input) {
|
|
474
|
+
input = input || {};
|
|
475
|
+
var now = input.now == null ? _now() : input.now;
|
|
476
|
+
_nonNegInt(now, "now");
|
|
477
|
+
|
|
478
|
+
var ts = _monotonicTs();
|
|
479
|
+
if (ts < now) ts = now; // forward-leap the monotonic to the supplied clock if needed
|
|
480
|
+
var r = await query(
|
|
481
|
+
"UPDATE inventory_holds SET status = 'expired', expired_at = ?1, " +
|
|
482
|
+
"release_reason = 'ttl_expired' " +
|
|
483
|
+
"WHERE status = 'held' AND expires_at <= ?2",
|
|
484
|
+
[ts, now],
|
|
485
|
+
);
|
|
486
|
+
return { expired_count: r.rowCount };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ---- metricsForSku ---------------------------------------------------
|
|
490
|
+
|
|
491
|
+
async function metricsForSku(input) {
|
|
492
|
+
if (!input || typeof input !== "object") {
|
|
493
|
+
throw new TypeError("inventory-allocations.metricsForSku: input object required");
|
|
494
|
+
}
|
|
495
|
+
_sku(input.sku);
|
|
496
|
+
_nonNegInt(input.from, "from");
|
|
497
|
+
_nonNegInt(input.to, "to");
|
|
498
|
+
if (input.to < input.from) {
|
|
499
|
+
throw new TypeError("inventory-allocations.metricsForSku: to must be ≥ from");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Count + sum-qty per status. Window predicate matches the row
|
|
503
|
+
// against the timestamp that corresponds to its current status:
|
|
504
|
+
// 'held' uses created_at, terminal statuses use their respective
|
|
505
|
+
// transition timestamp. This makes the metric semantically
|
|
506
|
+
// honest — a hold that opened in the window but committed
|
|
507
|
+
// outside it doesn't double-count.
|
|
508
|
+
var r = await query(
|
|
509
|
+
"SELECT status, COUNT(*) AS row_count, COALESCE(SUM(quantity), 0) AS qty_sum " +
|
|
510
|
+
"FROM inventory_holds " +
|
|
511
|
+
"WHERE sku = ?1 AND (" +
|
|
512
|
+
" (status = 'held' AND created_at BETWEEN ?2 AND ?3) OR " +
|
|
513
|
+
" (status = 'released' AND released_at BETWEEN ?2 AND ?3) OR " +
|
|
514
|
+
" (status = 'committed' AND committed_at BETWEEN ?2 AND ?3) OR " +
|
|
515
|
+
" (status = 'expired' AND expired_at BETWEEN ?2 AND ?3) " +
|
|
516
|
+
") " +
|
|
517
|
+
"GROUP BY status",
|
|
518
|
+
[input.sku, input.from, input.to],
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
var out = {
|
|
522
|
+
sku: input.sku,
|
|
523
|
+
from: input.from,
|
|
524
|
+
to: input.to,
|
|
525
|
+
held: { count: 0, quantity: 0 },
|
|
526
|
+
released: { count: 0, quantity: 0 },
|
|
527
|
+
committed: { count: 0, quantity: 0 },
|
|
528
|
+
expired: { count: 0, quantity: 0 },
|
|
529
|
+
};
|
|
530
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
531
|
+
var row = r.rows[i];
|
|
532
|
+
if (out[row.status]) {
|
|
533
|
+
out[row.status] = {
|
|
534
|
+
count: Number(row.row_count),
|
|
535
|
+
quantity: Number(row.qty_sum),
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return {
|
|
543
|
+
holdForCart: holdForCart,
|
|
544
|
+
releaseHold: releaseHold,
|
|
545
|
+
releaseAllForCart: releaseAllForCart,
|
|
546
|
+
commitHold: commitHold,
|
|
547
|
+
extendHold: extendHold,
|
|
548
|
+
availableForSku: availableForSku,
|
|
549
|
+
holdsForCart: holdsForCart,
|
|
550
|
+
cleanupExpiredHolds: cleanupExpiredHolds,
|
|
551
|
+
metricsForSku: metricsForSku,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
module.exports = {
|
|
556
|
+
create: create,
|
|
557
|
+
DEFAULT_TTL: DEFAULT_TTL,
|
|
558
|
+
MAX_TTL: MAX_TTL,
|
|
559
|
+
};
|