@blamejs/blamejs-shop 0.0.64 → 0.0.65
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 +2 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +20 -0
- package/lib/metered-usage.js +782 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/split-shipments.js +773 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.productBulkOps
|
|
4
|
+
* @title Product bulk operations — operator-side mass-mutation surface
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Composes on top of the `catalog` primitive to add the bulk mutation
|
|
8
|
+
* operations an operator console needs when a single change has to
|
|
9
|
+
* land across hundreds of products at once:
|
|
10
|
+
*
|
|
11
|
+
* bulkSetPrice — set a flat new amount_minor on every matched
|
|
12
|
+
* variant in the given currency (versioned —
|
|
13
|
+
* the prior price is closed via the catalog
|
|
14
|
+
* primitive's `prices.set` so price history is
|
|
15
|
+
* preserved).
|
|
16
|
+
* bulkAdjustPrice — apply a percent delta (e.g. -1500 bps for a
|
|
17
|
+
* 15% markdown) to every matched variant's
|
|
18
|
+
* current price in the given currency. The new
|
|
19
|
+
* amount is rounded half-away-from-zero in
|
|
20
|
+
* minor units; the floor is 0 (no negative
|
|
21
|
+
* prices). Variants without a current price in
|
|
22
|
+
* the target currency are skipped.
|
|
23
|
+
* bulkArchive — set products.status='archived' on every
|
|
24
|
+
* matched row that isn't already archived.
|
|
25
|
+
* bulkUnarchive — restore archived products to 'draft'.
|
|
26
|
+
* bulkAddTag — insert (product_id, tag) into product_tags
|
|
27
|
+
* for every matched product. Idempotent — the
|
|
28
|
+
* composite PK refuses duplicate tags on the
|
|
29
|
+
* same product, and the affected_count reflects
|
|
30
|
+
* actual inserts.
|
|
31
|
+
* bulkRemoveTag — delete (product_id, tag) rows. Idempotent on
|
|
32
|
+
* the no-such-tag case.
|
|
33
|
+
* bulkSetInventory — upsert inventory.stock_on_hand to a flat
|
|
34
|
+
* value for every variant of every matched
|
|
35
|
+
* product. Variants without an inventory row
|
|
36
|
+
* get one created at the target stock.
|
|
37
|
+
* previewFilter — resolve the filter to the matched product
|
|
38
|
+
* rows + count without mutating anything.
|
|
39
|
+
* The operator console renders this as the
|
|
40
|
+
* "you're about to change N rows" affordance.
|
|
41
|
+
* auditTrail — read the append-only product_bulk_audit
|
|
42
|
+
* rows back, optionally narrowed by kind /
|
|
43
|
+
* since / limit.
|
|
44
|
+
*
|
|
45
|
+
* Filter shape `{ skus?, vendor_slug?, category?, tag_any?, tag_all? }`:
|
|
46
|
+
*
|
|
47
|
+
* - `skus` — array of SKU strings. The matched products are
|
|
48
|
+
* the parents of variants matching any of these
|
|
49
|
+
* SKUs. Caps at 1000 entries per call.
|
|
50
|
+
* - `vendor_slug` — single slug string. Matches products whose any
|
|
51
|
+
* variant SKU is assigned to that vendor in
|
|
52
|
+
* `vendor_skus`.
|
|
53
|
+
* - `category` — single category string. Matches products with
|
|
54
|
+
* a row in `product_categories` for that
|
|
55
|
+
* category.
|
|
56
|
+
* - `tag_any` — array of tag strings; matches products with at
|
|
57
|
+
* least ONE of the tags.
|
|
58
|
+
* - `tag_all` — array of tag strings; matches products with
|
|
59
|
+
* EVERY tag.
|
|
60
|
+
*
|
|
61
|
+
* Multiple filter keys AND together. An empty filter `{}` is refused
|
|
62
|
+
* to refuse a "modify every product in the catalog" footgun — the
|
|
63
|
+
* operator passes an explicit `skus: [...]` to opt into a global
|
|
64
|
+
* change.
|
|
65
|
+
*
|
|
66
|
+
* Pre-flight cap: the resolved product set is capped at
|
|
67
|
+
* `MAX_BULK_ROWS` (default 1000) BEFORE any write fires. A filter
|
|
68
|
+
* that resolves to a larger set throws — the operator narrows the
|
|
69
|
+
* filter or raises the cap explicitly via `opts.maxBulkRows` at
|
|
70
|
+
* create() time.
|
|
71
|
+
*
|
|
72
|
+
* Audit trail: every bulk write inserts one row into
|
|
73
|
+
* `product_bulk_audit` with the resolved filter (JSON), the op
|
|
74
|
+
* parameters (JSON), and the actual affected count. The id is a v7
|
|
75
|
+
* UUID so the log sorts by insertion order.
|
|
76
|
+
*
|
|
77
|
+
* Atomicity story: pre-flight validation + cap check fire BEFORE any
|
|
78
|
+
* mutation. Once the per-row writes begin, each row's write runs in
|
|
79
|
+
* sequence; a partial failure (e.g. SQLite reports a CHECK constraint
|
|
80
|
+
* violation on row K) leaves rows 0..K-1 written and rows K..N
|
|
81
|
+
* unwritten. The audit row reflects the actual affected_count from
|
|
82
|
+
* `rowCount` summing, so post-hoc reconciliation sees the true tally.
|
|
83
|
+
* D1's per-statement boundary makes batch-level transactions
|
|
84
|
+
* unavailable; the in-batch ordering keeps the failure mode honest.
|
|
85
|
+
*
|
|
86
|
+
* Pricing math: `bulkAdjustPrice` takes `percent_bps` (basis points)
|
|
87
|
+
* so the operator passes integer values (-1500 = -15%, +500 = +5%).
|
|
88
|
+
* The new amount is `Math.round(prior * (10000 + percent_bps) /
|
|
89
|
+
* 10000)` clamped to 0. Tie-rounding follows `Math.round` (half-up
|
|
90
|
+
* towards +Infinity for positive numbers); the catalog primitive
|
|
91
|
+
* stores integer amount_minor only, so the round is unavoidable.
|
|
92
|
+
*
|
|
93
|
+
* The factory accepts:
|
|
94
|
+
* query — query function shared with `catalog`. Defaults to
|
|
95
|
+
* `b.externalDb`.
|
|
96
|
+
* catalog — REQUIRED. The catalog handle, used for variant
|
|
97
|
+
* lookups and price set.
|
|
98
|
+
* maxBulkRows — OPTIONAL. Caps the resolved product set per call
|
|
99
|
+
* (default 1000).
|
|
100
|
+
*
|
|
101
|
+
* @composes b.guardUuid, b.uuid.v7, b.safeSql, catalog.prices.set
|
|
102
|
+
*/
|
|
103
|
+
|
|
104
|
+
var bShop;
|
|
105
|
+
function _b() {
|
|
106
|
+
if (!bShop) bShop = require("./index");
|
|
107
|
+
return bShop.framework;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,198}[a-z0-9])?$/;
|
|
111
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
112
|
+
var TAG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
113
|
+
var CATEGORY_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
114
|
+
var CURRENCY_RE = /^[A-Z]{3}$/;
|
|
115
|
+
var DEFAULT_CAP = 1000;
|
|
116
|
+
var MAX_CAP = 10000;
|
|
117
|
+
var MAX_TAG_LIST = 32;
|
|
118
|
+
var MAX_SKU_LIST = 1000;
|
|
119
|
+
var MIN_BPS = -10000; // -100%, clamped to zero floor on apply
|
|
120
|
+
var MAX_BPS = 1000000; // +10000%, soft sanity ceiling
|
|
121
|
+
var AUDIT_KINDS = Object.freeze([
|
|
122
|
+
"set_price", "adjust_price", "archive", "unarchive",
|
|
123
|
+
"add_tag", "remove_tag", "set_inventory",
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
// ---- validators ----------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
function _uuid(s, label) {
|
|
129
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
130
|
+
catch (e) { throw new TypeError("productBulkOps: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
131
|
+
}
|
|
132
|
+
function _slug(s, label) {
|
|
133
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
134
|
+
throw new TypeError("productBulkOps: " + label + " must match /^[a-z0-9][a-z0-9-]*[a-z0-9]?$/ (lowercase alnum + dash, ≤ 200 chars)");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function _sku(s, label) {
|
|
138
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
139
|
+
throw new TypeError("productBulkOps: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ 128 chars)");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function _tag(s, label) {
|
|
143
|
+
if (typeof s !== "string" || !TAG_RE.test(s)) {
|
|
144
|
+
throw new TypeError("productBulkOps: " + label + " must match /^[a-z0-9][a-z0-9-]*[a-z0-9]?$/ (lowercase alnum + dash, 1-64 chars)");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function _category(s, label) {
|
|
148
|
+
if (typeof s !== "string" || !CATEGORY_RE.test(s)) {
|
|
149
|
+
throw new TypeError("productBulkOps: " + label + " must match /^[a-z0-9][a-z0-9-]*[a-z0-9]?$/ (lowercase alnum + dash, 1-64 chars)");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function _currency(s) {
|
|
153
|
+
if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
|
|
154
|
+
throw new TypeError("productBulkOps: currency must be a 3-letter ISO 4217 code (uppercase)");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function _nonNegInt(n, label) {
|
|
158
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
159
|
+
throw new TypeError("productBulkOps: " + label + " must be a non-negative integer");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function _bps(n) {
|
|
163
|
+
if (!Number.isInteger(n) || n < MIN_BPS || n > MAX_BPS) {
|
|
164
|
+
throw new TypeError("productBulkOps: percent_bps must be an integer in [" + MIN_BPS + ", " + MAX_BPS + "]");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
var _lastTs = 0;
|
|
168
|
+
function _now() {
|
|
169
|
+
var t = Date.now();
|
|
170
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
171
|
+
_lastTs = t;
|
|
172
|
+
return t;
|
|
173
|
+
}
|
|
174
|
+
function _id() { return _b().uuid.v7(); }
|
|
175
|
+
|
|
176
|
+
// ---- filter ---------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
function _validateFilter(filter, label) {
|
|
179
|
+
if (!filter || typeof filter !== "object" || Array.isArray(filter)) {
|
|
180
|
+
throw new TypeError("productBulkOps." + label + ": filter object required");
|
|
181
|
+
}
|
|
182
|
+
var keys = Object.keys(filter);
|
|
183
|
+
if (keys.length === 0) {
|
|
184
|
+
throw new TypeError("productBulkOps." + label + ": filter must specify at least one of skus / vendor_slug / category / tag_any / tag_all");
|
|
185
|
+
}
|
|
186
|
+
if (filter.skus !== undefined) {
|
|
187
|
+
if (!Array.isArray(filter.skus) || filter.skus.length === 0) {
|
|
188
|
+
throw new TypeError("productBulkOps." + label + ": filter.skus must be a non-empty array");
|
|
189
|
+
}
|
|
190
|
+
if (filter.skus.length > MAX_SKU_LIST) {
|
|
191
|
+
throw new TypeError("productBulkOps." + label + ": filter.skus must be ≤ " + MAX_SKU_LIST + " entries");
|
|
192
|
+
}
|
|
193
|
+
for (var i = 0; i < filter.skus.length; i += 1) {
|
|
194
|
+
_sku(filter.skus[i], "filter.skus[" + i + "]");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (filter.vendor_slug !== undefined) {
|
|
198
|
+
_slug(filter.vendor_slug, "filter.vendor_slug");
|
|
199
|
+
}
|
|
200
|
+
if (filter.category !== undefined) {
|
|
201
|
+
_category(filter.category, "filter.category");
|
|
202
|
+
}
|
|
203
|
+
if (filter.tag_any !== undefined) {
|
|
204
|
+
if (!Array.isArray(filter.tag_any) || filter.tag_any.length === 0) {
|
|
205
|
+
throw new TypeError("productBulkOps." + label + ": filter.tag_any must be a non-empty array");
|
|
206
|
+
}
|
|
207
|
+
if (filter.tag_any.length > MAX_TAG_LIST) {
|
|
208
|
+
throw new TypeError("productBulkOps." + label + ": filter.tag_any must be ≤ " + MAX_TAG_LIST + " entries");
|
|
209
|
+
}
|
|
210
|
+
for (var ai = 0; ai < filter.tag_any.length; ai += 1) {
|
|
211
|
+
_tag(filter.tag_any[ai], "filter.tag_any[" + ai + "]");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (filter.tag_all !== undefined) {
|
|
215
|
+
if (!Array.isArray(filter.tag_all) || filter.tag_all.length === 0) {
|
|
216
|
+
throw new TypeError("productBulkOps." + label + ": filter.tag_all must be a non-empty array");
|
|
217
|
+
}
|
|
218
|
+
if (filter.tag_all.length > MAX_TAG_LIST) {
|
|
219
|
+
throw new TypeError("productBulkOps." + label + ": filter.tag_all must be ≤ " + MAX_TAG_LIST + " entries");
|
|
220
|
+
}
|
|
221
|
+
for (var li = 0; li < filter.tag_all.length; li += 1) {
|
|
222
|
+
_tag(filter.tag_all[li], "filter.tag_all[" + li + "]");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Reject unknown keys so a typo doesn't silently widen the match.
|
|
226
|
+
var allowed = { skus: 1, vendor_slug: 1, category: 1, tag_any: 1, tag_all: 1 };
|
|
227
|
+
for (var k = 0; k < keys.length; k += 1) {
|
|
228
|
+
if (!allowed[keys[k]]) {
|
|
229
|
+
throw new TypeError("productBulkOps." + label + ": unknown filter key '" + keys[k] + "'");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Resolve the filter to a sorted array of product ids. Each filter
|
|
235
|
+
// key contributes a candidate set; the result is the intersection of
|
|
236
|
+
// those sets. An unfiltered key contributes "all product ids" — but
|
|
237
|
+
// since _validateFilter refuses the empty filter, at least one
|
|
238
|
+
// constraint is always present.
|
|
239
|
+
function _resolveFilter(query, filter, cap, label) {
|
|
240
|
+
var keysUsed = [];
|
|
241
|
+
if (filter.skus !== undefined) keysUsed.push("skus");
|
|
242
|
+
if (filter.vendor_slug !== undefined) keysUsed.push("vendor_slug");
|
|
243
|
+
if (filter.category !== undefined) keysUsed.push("category");
|
|
244
|
+
if (filter.tag_any !== undefined) keysUsed.push("tag_any");
|
|
245
|
+
if (filter.tag_all !== undefined) keysUsed.push("tag_all");
|
|
246
|
+
|
|
247
|
+
return _resolveFilterAsync(query, filter, keysUsed, cap, label);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function _resolveFilterAsync(query, filter, keysUsed, cap, label) {
|
|
251
|
+
var sets = [];
|
|
252
|
+
for (var i = 0; i < keysUsed.length; i += 1) {
|
|
253
|
+
var key = keysUsed[i];
|
|
254
|
+
var ids = await _idsForKey(query, key, filter[key]);
|
|
255
|
+
sets.push(ids);
|
|
256
|
+
}
|
|
257
|
+
// Intersect.
|
|
258
|
+
var result;
|
|
259
|
+
if (sets.length === 1) {
|
|
260
|
+
result = sets[0];
|
|
261
|
+
} else {
|
|
262
|
+
var smallest = sets[0];
|
|
263
|
+
for (var s = 1; s < sets.length; s += 1) {
|
|
264
|
+
if (sets[s].length < smallest.length) smallest = sets[s];
|
|
265
|
+
}
|
|
266
|
+
var lookup = {};
|
|
267
|
+
smallest.forEach(function (id) { lookup[id] = 1; });
|
|
268
|
+
for (var j = 0; j < sets.length; j += 1) {
|
|
269
|
+
if (sets[j] === smallest) continue;
|
|
270
|
+
var next = {};
|
|
271
|
+
for (var n = 0; n < sets[j].length; n += 1) {
|
|
272
|
+
if (lookup[sets[j][n]]) next[sets[j][n]] = 1;
|
|
273
|
+
}
|
|
274
|
+
lookup = next;
|
|
275
|
+
}
|
|
276
|
+
result = Object.keys(lookup);
|
|
277
|
+
}
|
|
278
|
+
result.sort();
|
|
279
|
+
if (result.length > cap) {
|
|
280
|
+
throw new TypeError("productBulkOps." + label + ": filter resolved to " + result.length + " products — exceeds cap of " + cap + " per call. Narrow the filter or raise opts.maxBulkRows at create() time.");
|
|
281
|
+
}
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Build the candidate id set for one filter key. Each clause produces
|
|
286
|
+
// distinct product ids; duplicates inside a result set are collapsed
|
|
287
|
+
// before return.
|
|
288
|
+
async function _idsForKey(query, key, value) {
|
|
289
|
+
var rows;
|
|
290
|
+
var seen;
|
|
291
|
+
var out;
|
|
292
|
+
var i;
|
|
293
|
+
if (key === "skus") {
|
|
294
|
+
var placeholders = [];
|
|
295
|
+
var params = [];
|
|
296
|
+
for (i = 0; i < value.length; i += 1) {
|
|
297
|
+
placeholders.push("?" + (i + 1));
|
|
298
|
+
params.push(value[i]);
|
|
299
|
+
}
|
|
300
|
+
rows = (await query(
|
|
301
|
+
"SELECT DISTINCT product_id FROM variants WHERE sku IN (" + placeholders.join(", ") + ")",
|
|
302
|
+
params,
|
|
303
|
+
)).rows;
|
|
304
|
+
} else if (key === "vendor_slug") {
|
|
305
|
+
rows = (await query(
|
|
306
|
+
"SELECT DISTINCT v.product_id FROM variants v " +
|
|
307
|
+
"INNER JOIN vendor_skus vs ON vs.sku = v.sku " +
|
|
308
|
+
"WHERE vs.vendor_slug = ?1",
|
|
309
|
+
[value],
|
|
310
|
+
)).rows;
|
|
311
|
+
} else if (key === "category") {
|
|
312
|
+
rows = (await query(
|
|
313
|
+
"SELECT product_id FROM product_categories WHERE category = ?1",
|
|
314
|
+
[value],
|
|
315
|
+
)).rows;
|
|
316
|
+
} else if (key === "tag_any") {
|
|
317
|
+
var ph = [];
|
|
318
|
+
var pa = [];
|
|
319
|
+
for (i = 0; i < value.length; i += 1) {
|
|
320
|
+
ph.push("?" + (i + 1));
|
|
321
|
+
pa.push(value[i]);
|
|
322
|
+
}
|
|
323
|
+
rows = (await query(
|
|
324
|
+
"SELECT DISTINCT product_id FROM product_tags WHERE tag IN (" + ph.join(", ") + ")",
|
|
325
|
+
pa,
|
|
326
|
+
)).rows;
|
|
327
|
+
} else if (key === "tag_all") {
|
|
328
|
+
// Products with every requested tag — group by product_id and
|
|
329
|
+
// require COUNT(DISTINCT tag) == requested length.
|
|
330
|
+
var ph2 = [];
|
|
331
|
+
var pa2 = [];
|
|
332
|
+
for (i = 0; i < value.length; i += 1) {
|
|
333
|
+
ph2.push("?" + (i + 1));
|
|
334
|
+
pa2.push(value[i]);
|
|
335
|
+
}
|
|
336
|
+
pa2.push(value.length);
|
|
337
|
+
rows = (await query(
|
|
338
|
+
"SELECT product_id FROM product_tags WHERE tag IN (" + ph2.join(", ") + ") " +
|
|
339
|
+
"GROUP BY product_id HAVING COUNT(DISTINCT tag) = ?" + (value.length + 1),
|
|
340
|
+
pa2,
|
|
341
|
+
)).rows;
|
|
342
|
+
} else {
|
|
343
|
+
rows = [];
|
|
344
|
+
}
|
|
345
|
+
seen = {};
|
|
346
|
+
out = [];
|
|
347
|
+
for (i = 0; i < rows.length; i += 1) {
|
|
348
|
+
var pid = rows[i].product_id;
|
|
349
|
+
if (!seen[pid]) { seen[pid] = 1; out.push(pid); }
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---- factory --------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
function create(opts) {
|
|
357
|
+
opts = opts || {};
|
|
358
|
+
if (!opts.catalog) {
|
|
359
|
+
throw new TypeError("productBulkOps.create: catalog handle required");
|
|
360
|
+
}
|
|
361
|
+
var catalog = opts.catalog;
|
|
362
|
+
var maxBulkRows = opts.maxBulkRows == null ? DEFAULT_CAP : opts.maxBulkRows;
|
|
363
|
+
if (!Number.isInteger(maxBulkRows) || maxBulkRows <= 0 || maxBulkRows > MAX_CAP) {
|
|
364
|
+
throw new TypeError("productBulkOps.create: maxBulkRows must be a positive integer ≤ " + MAX_CAP);
|
|
365
|
+
}
|
|
366
|
+
var query = opts.query;
|
|
367
|
+
if (!query) {
|
|
368
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Write one append-only audit row. Drop-noise: a failed audit-row
|
|
372
|
+
// insert here is a hard error — the audit trail is the contract
|
|
373
|
+
// every bulk op honours. The catalog primitive owns the rollback
|
|
374
|
+
// story for the data writes; this row is the breadcrumb so the
|
|
375
|
+
// operator can reconcile after the fact.
|
|
376
|
+
async function _writeAudit(kind, filter, params, affectedCount, actorId) {
|
|
377
|
+
var id = _id();
|
|
378
|
+
var ts = _now();
|
|
379
|
+
await query(
|
|
380
|
+
"INSERT INTO product_bulk_audit " +
|
|
381
|
+
"(id, kind, filter_json, params_json, affected_count, performed_at, actor_id) " +
|
|
382
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
383
|
+
[
|
|
384
|
+
id, kind,
|
|
385
|
+
JSON.stringify(filter || {}),
|
|
386
|
+
JSON.stringify(params || {}),
|
|
387
|
+
affectedCount, ts,
|
|
388
|
+
actorId == null ? null : actorId,
|
|
389
|
+
],
|
|
390
|
+
);
|
|
391
|
+
return {
|
|
392
|
+
id: id,
|
|
393
|
+
kind: kind,
|
|
394
|
+
filter: filter,
|
|
395
|
+
params: params,
|
|
396
|
+
affected_count: affectedCount,
|
|
397
|
+
performed_at: ts,
|
|
398
|
+
actor_id: actorId == null ? null : actorId,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function _maybeActor(input) {
|
|
403
|
+
if (input.actor_id == null) return null;
|
|
404
|
+
return _uuid(input.actor_id, "actor_id");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Resolve filter → product ids, cap-checked, ready for write loop.
|
|
408
|
+
async function _matchProductIds(filter, label) {
|
|
409
|
+
_validateFilter(filter, label);
|
|
410
|
+
return await _resolveFilter(query, filter, maxBulkRows, label);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Load every variant row for the matched product set. Variants are
|
|
414
|
+
// the unit price + inventory mutations target; products own the
|
|
415
|
+
// status + tag set + category set.
|
|
416
|
+
async function _variantsForProducts(productIds) {
|
|
417
|
+
if (productIds.length === 0) return [];
|
|
418
|
+
var ph = [];
|
|
419
|
+
var pa = [];
|
|
420
|
+
for (var i = 0; i < productIds.length; i += 1) {
|
|
421
|
+
ph.push("?" + (i + 1));
|
|
422
|
+
pa.push(productIds[i]);
|
|
423
|
+
}
|
|
424
|
+
var r = await query(
|
|
425
|
+
"SELECT id, product_id, sku FROM variants WHERE product_id IN (" + ph.join(", ") + ")",
|
|
426
|
+
pa,
|
|
427
|
+
);
|
|
428
|
+
return r.rows;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return {
|
|
432
|
+
AUDIT_KINDS: AUDIT_KINDS,
|
|
433
|
+
MAX_BULK_ROWS: maxBulkRows,
|
|
434
|
+
DEFAULT_CAP: DEFAULT_CAP,
|
|
435
|
+
|
|
436
|
+
// Read-only — render the matched product set for an operator
|
|
437
|
+
// preview affordance. Never mutates anything.
|
|
438
|
+
previewFilter: async function (input) {
|
|
439
|
+
if (!input || typeof input !== "object") throw new TypeError("productBulkOps.previewFilter: input object required");
|
|
440
|
+
var filter = input.filter;
|
|
441
|
+
_validateFilter(filter, "previewFilter");
|
|
442
|
+
var ids = await _resolveFilter(query, filter, maxBulkRows, "previewFilter");
|
|
443
|
+
if (ids.length === 0) {
|
|
444
|
+
return { matched: 0, product_ids: [], products: [] };
|
|
445
|
+
}
|
|
446
|
+
var ph = [];
|
|
447
|
+
var pa = [];
|
|
448
|
+
for (var i = 0; i < ids.length; i += 1) {
|
|
449
|
+
ph.push("?" + (i + 1));
|
|
450
|
+
pa.push(ids[i]);
|
|
451
|
+
}
|
|
452
|
+
var r = await query(
|
|
453
|
+
"SELECT * FROM products WHERE id IN (" + ph.join(", ") + ") ORDER BY id ASC",
|
|
454
|
+
pa,
|
|
455
|
+
);
|
|
456
|
+
return {
|
|
457
|
+
matched: r.rows.length,
|
|
458
|
+
product_ids: ids,
|
|
459
|
+
products: r.rows,
|
|
460
|
+
};
|
|
461
|
+
},
|
|
462
|
+
|
|
463
|
+
// Set a flat amount_minor on every matched variant in the given
|
|
464
|
+
// currency. Goes through catalog.prices.set so the prior price
|
|
465
|
+
// is versioned (closed with effective_until) rather than
|
|
466
|
+
// overwritten.
|
|
467
|
+
bulkSetPrice: async function (input) {
|
|
468
|
+
if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkSetPrice: input object required");
|
|
469
|
+
var filter = input.filter;
|
|
470
|
+
_currency(input.currency);
|
|
471
|
+
_nonNegInt(input.amount_minor, "amount_minor");
|
|
472
|
+
var actorId = _maybeActor(input);
|
|
473
|
+
var productIds = await _matchProductIds(filter, "bulkSetPrice");
|
|
474
|
+
var variants = await _variantsForProducts(productIds);
|
|
475
|
+
var affected = 0;
|
|
476
|
+
for (var i = 0; i < variants.length; i += 1) {
|
|
477
|
+
await catalog.prices.set(variants[i].id, {
|
|
478
|
+
currency: input.currency,
|
|
479
|
+
amount_minor: input.amount_minor,
|
|
480
|
+
});
|
|
481
|
+
affected += 1;
|
|
482
|
+
}
|
|
483
|
+
var paramsOut = { currency: input.currency, amount_minor: input.amount_minor };
|
|
484
|
+
var auditRow = await _writeAudit("set_price", filter, paramsOut, affected, actorId);
|
|
485
|
+
return {
|
|
486
|
+
affected_count: affected,
|
|
487
|
+
matched_products: productIds.length,
|
|
488
|
+
matched_variants: variants.length,
|
|
489
|
+
audit_id: auditRow.id,
|
|
490
|
+
};
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
// Apply a percent_bps delta to every matched variant's CURRENT
|
|
494
|
+
// price in the given currency. Variants without a current price
|
|
495
|
+
// in the target currency are skipped (they don't have a "before"
|
|
496
|
+
// amount to multiply against). The new amount is rounded with
|
|
497
|
+
// Math.round and floored at 0.
|
|
498
|
+
bulkAdjustPrice: async function (input) {
|
|
499
|
+
if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkAdjustPrice: input object required");
|
|
500
|
+
var filter = input.filter;
|
|
501
|
+
_currency(input.currency);
|
|
502
|
+
_bps(input.percent_bps);
|
|
503
|
+
var actorId = _maybeActor(input);
|
|
504
|
+
var productIds = await _matchProductIds(filter, "bulkAdjustPrice");
|
|
505
|
+
var variants = await _variantsForProducts(productIds);
|
|
506
|
+
var affected = 0;
|
|
507
|
+
var skipped = 0;
|
|
508
|
+
var multiplier = (10000 + input.percent_bps) / 10000;
|
|
509
|
+
for (var i = 0; i < variants.length; i += 1) {
|
|
510
|
+
var current = await catalog.prices.current(variants[i].id, input.currency);
|
|
511
|
+
if (!current) { skipped += 1; continue; }
|
|
512
|
+
var next = Math.round(current.amount_minor * multiplier);
|
|
513
|
+
if (next < 0) next = 0;
|
|
514
|
+
await catalog.prices.set(variants[i].id, {
|
|
515
|
+
currency: input.currency,
|
|
516
|
+
amount_minor: next,
|
|
517
|
+
});
|
|
518
|
+
affected += 1;
|
|
519
|
+
}
|
|
520
|
+
var paramsOut = { currency: input.currency, percent_bps: input.percent_bps };
|
|
521
|
+
var auditRow = await _writeAudit("adjust_price", filter, paramsOut, affected, actorId);
|
|
522
|
+
return {
|
|
523
|
+
affected_count: affected,
|
|
524
|
+
skipped_count: skipped,
|
|
525
|
+
matched_products: productIds.length,
|
|
526
|
+
matched_variants: variants.length,
|
|
527
|
+
audit_id: auditRow.id,
|
|
528
|
+
};
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
bulkArchive: async function (input) {
|
|
532
|
+
if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkArchive: input object required");
|
|
533
|
+
var filter = input.filter;
|
|
534
|
+
var actorId = _maybeActor(input);
|
|
535
|
+
var productIds = await _matchProductIds(filter, "bulkArchive");
|
|
536
|
+
if (productIds.length === 0) {
|
|
537
|
+
var emptyRow = await _writeAudit("archive", filter, {}, 0, actorId);
|
|
538
|
+
return { affected_count: 0, matched_products: 0, audit_id: emptyRow.id };
|
|
539
|
+
}
|
|
540
|
+
var ph = [];
|
|
541
|
+
var pa = [];
|
|
542
|
+
for (var i = 0; i < productIds.length; i += 1) {
|
|
543
|
+
ph.push("?" + (i + 1));
|
|
544
|
+
pa.push(productIds[i]);
|
|
545
|
+
}
|
|
546
|
+
pa.push(_now());
|
|
547
|
+
var r = await query(
|
|
548
|
+
"UPDATE products SET status = 'archived', updated_at = ?" + (productIds.length + 1) + " " +
|
|
549
|
+
"WHERE id IN (" + ph.join(", ") + ") AND status != 'archived'",
|
|
550
|
+
pa,
|
|
551
|
+
);
|
|
552
|
+
var affected = r.rowCount;
|
|
553
|
+
var auditRow = await _writeAudit("archive", filter, {}, affected, actorId);
|
|
554
|
+
return {
|
|
555
|
+
affected_count: affected,
|
|
556
|
+
matched_products: productIds.length,
|
|
557
|
+
audit_id: auditRow.id,
|
|
558
|
+
};
|
|
559
|
+
},
|
|
560
|
+
|
|
561
|
+
bulkUnarchive: async function (input) {
|
|
562
|
+
if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkUnarchive: input object required");
|
|
563
|
+
var filter = input.filter;
|
|
564
|
+
var actorId = _maybeActor(input);
|
|
565
|
+
var productIds = await _matchProductIds(filter, "bulkUnarchive");
|
|
566
|
+
if (productIds.length === 0) {
|
|
567
|
+
var emptyRow = await _writeAudit("unarchive", filter, {}, 0, actorId);
|
|
568
|
+
return { affected_count: 0, matched_products: 0, audit_id: emptyRow.id };
|
|
569
|
+
}
|
|
570
|
+
var ph = [];
|
|
571
|
+
var pa = [];
|
|
572
|
+
for (var i = 0; i < productIds.length; i += 1) {
|
|
573
|
+
ph.push("?" + (i + 1));
|
|
574
|
+
pa.push(productIds[i]);
|
|
575
|
+
}
|
|
576
|
+
pa.push(_now());
|
|
577
|
+
var r = await query(
|
|
578
|
+
"UPDATE products SET status = 'draft', updated_at = ?" + (productIds.length + 1) + " " +
|
|
579
|
+
"WHERE id IN (" + ph.join(", ") + ") AND status = 'archived'",
|
|
580
|
+
pa,
|
|
581
|
+
);
|
|
582
|
+
var affected = r.rowCount;
|
|
583
|
+
var auditRow = await _writeAudit("unarchive", filter, {}, affected, actorId);
|
|
584
|
+
return {
|
|
585
|
+
affected_count: affected,
|
|
586
|
+
matched_products: productIds.length,
|
|
587
|
+
audit_id: auditRow.id,
|
|
588
|
+
};
|
|
589
|
+
},
|
|
590
|
+
|
|
591
|
+
// Insert (product_id, tag) for every matched product. The PK
|
|
592
|
+
// refuses duplicates so a re-add is a no-op per row. We do the
|
|
593
|
+
// insert one row at a time and count successes — INSERT OR
|
|
594
|
+
// IGNORE keeps the loop forward-progressing without a per-row
|
|
595
|
+
// SELECT-then-INSERT race.
|
|
596
|
+
bulkAddTag: async function (input) {
|
|
597
|
+
if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkAddTag: input object required");
|
|
598
|
+
var filter = input.filter;
|
|
599
|
+
_tag(input.tag, "tag");
|
|
600
|
+
var actorId = _maybeActor(input);
|
|
601
|
+
var productIds = await _matchProductIds(filter, "bulkAddTag");
|
|
602
|
+
var ts = _now();
|
|
603
|
+
var affected = 0;
|
|
604
|
+
for (var i = 0; i < productIds.length; i += 1) {
|
|
605
|
+
var r = await query(
|
|
606
|
+
"INSERT OR IGNORE INTO product_tags (product_id, tag, added_at) VALUES (?1, ?2, ?3)",
|
|
607
|
+
[productIds[i], input.tag, ts],
|
|
608
|
+
);
|
|
609
|
+
affected += r.rowCount;
|
|
610
|
+
}
|
|
611
|
+
var paramsOut = { tag: input.tag };
|
|
612
|
+
var auditRow = await _writeAudit("add_tag", filter, paramsOut, affected, actorId);
|
|
613
|
+
return {
|
|
614
|
+
affected_count: affected,
|
|
615
|
+
matched_products: productIds.length,
|
|
616
|
+
audit_id: auditRow.id,
|
|
617
|
+
};
|
|
618
|
+
},
|
|
619
|
+
|
|
620
|
+
bulkRemoveTag: async function (input) {
|
|
621
|
+
if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkRemoveTag: input object required");
|
|
622
|
+
var filter = input.filter;
|
|
623
|
+
_tag(input.tag, "tag");
|
|
624
|
+
var actorId = _maybeActor(input);
|
|
625
|
+
var productIds = await _matchProductIds(filter, "bulkRemoveTag");
|
|
626
|
+
if (productIds.length === 0) {
|
|
627
|
+
var emptyRow = await _writeAudit("remove_tag", filter, { tag: input.tag }, 0, actorId);
|
|
628
|
+
return { affected_count: 0, matched_products: 0, audit_id: emptyRow.id };
|
|
629
|
+
}
|
|
630
|
+
var ph = [];
|
|
631
|
+
var pa = [];
|
|
632
|
+
for (var i = 0; i < productIds.length; i += 1) {
|
|
633
|
+
ph.push("?" + (i + 1));
|
|
634
|
+
pa.push(productIds[i]);
|
|
635
|
+
}
|
|
636
|
+
pa.push(input.tag);
|
|
637
|
+
var r = await query(
|
|
638
|
+
"DELETE FROM product_tags WHERE product_id IN (" + ph.join(", ") + ") AND tag = ?" + (productIds.length + 1),
|
|
639
|
+
pa,
|
|
640
|
+
);
|
|
641
|
+
var affected = r.rowCount;
|
|
642
|
+
var auditRow = await _writeAudit("remove_tag", filter, { tag: input.tag }, affected, actorId);
|
|
643
|
+
return {
|
|
644
|
+
affected_count: affected,
|
|
645
|
+
matched_products: productIds.length,
|
|
646
|
+
audit_id: auditRow.id,
|
|
647
|
+
};
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
// Upsert inventory.stock_on_hand to a flat value for every
|
|
651
|
+
// variant of every matched product. Missing inventory rows are
|
|
652
|
+
// created at the target stock. `stock_held` is preserved on
|
|
653
|
+
// existing rows — the operator changes "on hand", not the
|
|
654
|
+
// checkout-side reservation count.
|
|
655
|
+
bulkSetInventory: async function (input) {
|
|
656
|
+
if (!input || typeof input !== "object") throw new TypeError("productBulkOps.bulkSetInventory: input object required");
|
|
657
|
+
var filter = input.filter;
|
|
658
|
+
_nonNegInt(input.stock_on_hand, "stock_on_hand");
|
|
659
|
+
var actorId = _maybeActor(input);
|
|
660
|
+
var productIds = await _matchProductIds(filter, "bulkSetInventory");
|
|
661
|
+
var variants = await _variantsForProducts(productIds);
|
|
662
|
+
var ts = _now();
|
|
663
|
+
var affected = 0;
|
|
664
|
+
for (var i = 0; i < variants.length; i += 1) {
|
|
665
|
+
var sku = variants[i].sku;
|
|
666
|
+
var existing = (await query(
|
|
667
|
+
"SELECT sku FROM inventory WHERE sku = ?1",
|
|
668
|
+
[sku],
|
|
669
|
+
)).rows[0];
|
|
670
|
+
if (existing) {
|
|
671
|
+
var u = await query(
|
|
672
|
+
"UPDATE inventory SET stock_on_hand = ?1, updated_at = ?2 WHERE sku = ?3",
|
|
673
|
+
[input.stock_on_hand, ts, sku],
|
|
674
|
+
);
|
|
675
|
+
affected += u.rowCount;
|
|
676
|
+
} else {
|
|
677
|
+
await query(
|
|
678
|
+
"INSERT INTO inventory (sku, stock_on_hand, stock_held, updated_at) VALUES (?1, ?2, 0, ?3)",
|
|
679
|
+
[sku, input.stock_on_hand, ts],
|
|
680
|
+
);
|
|
681
|
+
affected += 1;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
var paramsOut = { stock_on_hand: input.stock_on_hand };
|
|
685
|
+
var auditRow = await _writeAudit("set_inventory", filter, paramsOut, affected, actorId);
|
|
686
|
+
return {
|
|
687
|
+
affected_count: affected,
|
|
688
|
+
matched_products: productIds.length,
|
|
689
|
+
matched_variants: variants.length,
|
|
690
|
+
audit_id: auditRow.id,
|
|
691
|
+
};
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
// Append-only audit-trail reader. Narrow by kind / since / limit
|
|
695
|
+
// / actor_id; default is "newest 50 across every kind".
|
|
696
|
+
auditTrail: async function (opts2) {
|
|
697
|
+
opts2 = opts2 || {};
|
|
698
|
+
var clauses = [];
|
|
699
|
+
var params = [];
|
|
700
|
+
var idx = 1;
|
|
701
|
+
if (opts2.kind !== undefined) {
|
|
702
|
+
if (AUDIT_KINDS.indexOf(opts2.kind) === -1) {
|
|
703
|
+
throw new TypeError("productBulkOps.auditTrail: kind must be one of " + AUDIT_KINDS.join(", "));
|
|
704
|
+
}
|
|
705
|
+
clauses.push("kind = ?" + (idx++));
|
|
706
|
+
params.push(opts2.kind);
|
|
707
|
+
}
|
|
708
|
+
if (opts2.since !== undefined) {
|
|
709
|
+
if (!Number.isInteger(opts2.since) || opts2.since < 0) {
|
|
710
|
+
throw new TypeError("productBulkOps.auditTrail: since must be a non-negative epoch ms integer");
|
|
711
|
+
}
|
|
712
|
+
clauses.push("performed_at >= ?" + (idx++));
|
|
713
|
+
params.push(opts2.since);
|
|
714
|
+
}
|
|
715
|
+
if (opts2.actor_id !== undefined) {
|
|
716
|
+
var actor = _uuid(opts2.actor_id, "actor_id");
|
|
717
|
+
clauses.push("actor_id = ?" + (idx++));
|
|
718
|
+
params.push(actor);
|
|
719
|
+
}
|
|
720
|
+
var limit = opts2.limit == null ? 50 : opts2.limit;
|
|
721
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > 500) {
|
|
722
|
+
throw new TypeError("productBulkOps.auditTrail: limit must be 1..500");
|
|
723
|
+
}
|
|
724
|
+
var sql = "SELECT * FROM product_bulk_audit" +
|
|
725
|
+
(clauses.length ? " WHERE " + clauses.join(" AND ") : "") +
|
|
726
|
+
" ORDER BY performed_at DESC, id DESC LIMIT ?" + idx;
|
|
727
|
+
params.push(limit);
|
|
728
|
+
var r = await query(sql, params);
|
|
729
|
+
return r.rows.map(function (row) {
|
|
730
|
+
return {
|
|
731
|
+
id: row.id,
|
|
732
|
+
kind: row.kind,
|
|
733
|
+
filter: JSON.parse(row.filter_json),
|
|
734
|
+
params: JSON.parse(row.params_json),
|
|
735
|
+
affected_count: row.affected_count,
|
|
736
|
+
performed_at: row.performed_at,
|
|
737
|
+
actor_id: row.actor_id,
|
|
738
|
+
};
|
|
739
|
+
});
|
|
740
|
+
},
|
|
741
|
+
|
|
742
|
+
// Small admin surface for the category join table — used by the
|
|
743
|
+
// operator console to assign / remove a category from a product.
|
|
744
|
+
// The bulk-ops primitive owns this because the table is its
|
|
745
|
+
// domain (the filter resolution is the only consumer).
|
|
746
|
+
categories: {
|
|
747
|
+
assign: async function (productId, category) {
|
|
748
|
+
_uuid(productId, "product_id");
|
|
749
|
+
_category(category, "category");
|
|
750
|
+
var ts = _now();
|
|
751
|
+
await query(
|
|
752
|
+
"INSERT OR IGNORE INTO product_categories (product_id, category, added_at) VALUES (?1, ?2, ?3)",
|
|
753
|
+
[productId, category, ts],
|
|
754
|
+
);
|
|
755
|
+
return { product_id: productId, category: category, added_at: ts };
|
|
756
|
+
},
|
|
757
|
+
remove: async function (productId, category) {
|
|
758
|
+
_uuid(productId, "product_id");
|
|
759
|
+
_category(category, "category");
|
|
760
|
+
var r = await query(
|
|
761
|
+
"DELETE FROM product_categories WHERE product_id = ?1 AND category = ?2",
|
|
762
|
+
[productId, category],
|
|
763
|
+
);
|
|
764
|
+
return { removed: r.rowCount };
|
|
765
|
+
},
|
|
766
|
+
listForProduct: async function (productId) {
|
|
767
|
+
_uuid(productId, "product_id");
|
|
768
|
+
var r = await query(
|
|
769
|
+
"SELECT category FROM product_categories WHERE product_id = ?1 ORDER BY category ASC",
|
|
770
|
+
[productId],
|
|
771
|
+
);
|
|
772
|
+
return r.rows.map(function (row) { return row.category; });
|
|
773
|
+
},
|
|
774
|
+
},
|
|
775
|
+
|
|
776
|
+
// Same small admin surface for the tag table — operators address
|
|
777
|
+
// single-product tag mutations through here; the bulk paths are
|
|
778
|
+
// the loop-N variant.
|
|
779
|
+
tags: {
|
|
780
|
+
listForProduct: async function (productId) {
|
|
781
|
+
_uuid(productId, "product_id");
|
|
782
|
+
var r = await query(
|
|
783
|
+
"SELECT tag FROM product_tags WHERE product_id = ?1 ORDER BY tag ASC",
|
|
784
|
+
[productId],
|
|
785
|
+
);
|
|
786
|
+
return r.rows.map(function (row) { return row.tag; });
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
module.exports = {
|
|
793
|
+
create: create,
|
|
794
|
+
AUDIT_KINDS: AUDIT_KINDS,
|
|
795
|
+
DEFAULT_CAP: DEFAULT_CAP,
|
|
796
|
+
MAX_CAP: MAX_CAP,
|
|
797
|
+
};
|