Setting the file. One moment. Stores Inventory · Rp Target Wix · wix/skills · Skills Docs41.10
Multilingual Restore
147
function correlateBulk
— line 147
This file
- Number
- 41.166
- Position
- 166 of 174
- Type
- JavaScript
- Size
- 15 KB
- Lines
- 250
lib/stores-inventory.js
JavaScript·250 lines·15 KB
7
const
OUTCOMES
=
[
'reconciled'
,
'created'
,
'updated'
,
'failed'
,
'unverified'
];
8
9// Inventory's tracking method is a oneof. trackQuantity is derived, never writable.
10function stockPayload(value) {
11 if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Inventory needs an explicit tracking method');
12 if (Object.keys(value).some((key) => !['inStock', 'quantity', 'trackQuantity'].includes(key))) throw new Error('Unsupported inventory stock field');
13 const hasStock = own(value, 'inStock');
14 const hasQuantity = own(value, 'quantity');
15 if (hasStock === hasQuantity) throw new Error('Inventory needs exactly one of inStock or quantity');
16 if (hasStock && typeof value.inStock !== 'boolean') throw new Error('inStock must be boolean');
17 if (hasQuantity && (!Number.isInteger(value.quantity) || value.quantity < 0 || value.quantity > 99999)) {
18 throw new Error('Inventory quantity must be an integer from 0 to 99999');
19 }
20 if (own(value, 'trackQuantity') && value.trackQuantity !== hasQuantity) throw new Error('Conflicting trackQuantity');
21 return hasStock ? { inStock: value.inStock } : { quantity: value.quantity };
22}
23
24function inventoryForVariant(variant, { inStock } = {}) {
25 const has = (key) => own(variant, key) && variant[key] != null && variant[key] !== '';
26 if (has('inventoryTracked') && typeof variant.inventoryTracked !== 'boolean') throw new Error('inventoryTracked must be boolean');
27 if (has('inStock') && typeof variant.inStock !== 'boolean') throw new Error('inStock must be boolean');
28 if (variant.inventoryTracked === true || (has('inventoryQuantity') && variant.inventoryTracked !== false)) {
29 const result = stockPayload({ quantity: variant.inventoryQuantity });
30 if (has('inStock') && variant.inStock !== (result.quantity > 0)) throw new Error('Conflicting inventory status and quantity');
31 return result;
32 }
33 if (variant.inventoryTracked === false && has('inventoryQuantity')) throw new Error('Untracked inventory cannot carry a quantity');
34 if (has('inStock')) return stockPayload({ inStock: variant.inStock });
35 if (variant.inventoryTracked === false) throw new Error('Untracked inventory needs explicit inStock');
36 if (inStock !== undefined) return stockPayload({ inStock });
37 return null;
38}
39
40function identity(value, name) {
41 if (typeof value !== 'string' || !value.trim()) throw new Error(`Inventory requires ${name}`);
42 return value;
43}
44
45async function resolveInventoryLocationDetails(wix, locationId) {
46 const filter = locationId ? { id: identity(locationId, 'locationId') } : { defaultLocation: true };
47 const response = await wix.send(require('./wix-writers').buildQueryInventoryLocationsRequest(filter));
48 const rows = response && response.storesLocations;
49 if (!Array.isArray(rows) || rows.length !== 1 || !rows[0].id ||
50 (locationId ? rows[0].id !== locationId : rows[0].defaultLocation !== true)) {
51 throw new Error('Inventory location must resolve to exactly one matching Stores location');
52 }
53 if (typeof rows[0].defaultLocation !== 'boolean') throw new Error('Stores location omitted its default-location status');
54 return rows[0];
55}
56
57async function resolveInventoryLocation(wix, locationId) {
58 return (await resolveInventoryLocationDetails(wix, locationId)).id;
59}
60
61// Complete, bounded read: missing payloads and repeating cursors never mean an empty target.
62async function readInventory(wix, rows, locationId) {
63 const wanted = new Map(rows.map((r) => [r.variantId, r]));
64 const found = new Map();
65 const cursors = new Set();
66 let cursor;
67 do {
68 const response = await wix.send(require('./wix-writers').buildQueryInventoryItemsRequest({ ...(!cursor ? { filter: { variantId: { $in: [...wanted.keys()] }, locationId } } : {}), cursorPaging: { limit: BATCH_LIMIT, ...(cursor ? { cursor } : {}) } }));
69 if (!response || !Array.isArray(response.inventoryItems) || !response.pagingMetadata || typeof response.pagingMetadata !== 'object') throw new Error('Malformed inventory query response');
70 for (const item of response.inventoryItems) {
71 const expected = wanted.get(item.variantId);
72 if (!expected || item.productId !== expected.productId || item.locationId !== locationId || !item.id) {
73 throw new Error('Inventory query returned a mismatched identity');
74 }
75 if (found.has(item.variantId)) throw new Error('Duplicate inventory identity');
76 found.set(item.variantId, item);
77 }
78 cursor = response.pagingMetadata && response.pagingMetadata.cursors && response.pagingMetadata.cursors.next;
79 if (response.pagingMetadata.hasNext === true && !cursor) throw new Error('Inventory query omitted its next cursor');
80 if (cursor && (typeof cursor !== 'string' || cursors.has(cursor))) throw new Error('Invalid inventory paging cursor');
81 if (cursor) cursors.add(cursor);
82 // At most one row per requested variant/location. A full extra page cannot be legitimate.
83 if (cursors.size > rows.length + 1) throw new Error('Inventory query exceeded its bounded population');
84 } while (cursor);
85 return found;
86}
87
88function matchesInventory(item, inventory) {
89 if (!item || (item.preorderInfo && item.preorderInfo.enabled)) return false;
90 const tracked = own(inventory, 'quantity');
91 const available = tracked ? inventory.quantity > 0 : inventory.inStock;
92 return item.trackQuantity === tracked &&
93 (tracked ? item.quantity === inventory.quantity : item.inStock === inventory.inStock) &&
94 item.availabilityStatus === (available ? 'IN_STOCK' : 'OUT_OF_STOCK');
95}
96
97// Catalog is a separate event-driven projection of default-location inventory.
98// Check exact variant IDs even on no-write reruns; never rewrite stock to force propagation.
99async function verifyCatalogInventory(wix, rows, { verifyAttempts = 3, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
100 const groups = new Map();
101 for (const row of rows) {
102 if (!['reconciled', 'created', 'updated'].includes(row.outcome)) continue;
103 if (!groups.has(row.productId)) groups.set(row.productId, []);
104 groups.get(row.productId).push({ row, inventoryOutcome: row.outcome });
105 }
106 const pending = [...groups.entries()];
107 let index = 0;
108 async function worker() {
109 while (index < pending.length) {
110 const [productId, entries] = pending[index++];
111 for (let attempt = 0; attempt < verifyAttempts; attempt++) {
112 try {
113 const response = await wix.send(require('./wix-writers').buildGetStoresProductRequest(productId));
114 const product = response && response.product;
115 if (!product || product.id !== productId || !product.variantsInfo || !Array.isArray(product.variantsInfo.variants)) {
116 throw new Error('Catalog read omitted the expected product/variants');
117 }
118 const variants = new Map();
119 for (const variant of product.variantsInfo.variants) {
120 if (!variant.id || variants.has(variant.id)) throw new Error('Catalog read returned invalid or duplicate variant identity');
121 variants.set(variant.id, variant);
122 }
123 for (const { row, inventoryOutcome } of entries) {
124 const variant = variants.get(row.variantId);
125 const expected = own(row.inventory, 'quantity') ? row.inventory.quantity > 0 : row.inventory.inStock;
126 const status = variant && variant.inventoryStatus;
127 const matches = status && status.inStock === expected && status.preorderEnabled === false;
128 row.catalogVerification = { status: matches ? 'verified' : 'unverified', expectedInStock: expected,
129 actualInStock: status && typeof status.inStock === 'boolean' ? status.inStock : null };
130 row.outcome = matches ? inventoryOutcome : 'unverified';
131 if (matches) delete row.error;
132 else row.error = 'Catalog variant availability does not confirm default-location inventory';
133 }
134 if (entries.every(({ row }) => row.catalogVerification.status === 'verified')) break;
135 } catch (error) {
136 if (wixAuth.isRunInterruption(error)) throw error;
137 for (const { row } of entries) Object.assign(row, { outcome: 'unverified', error: error.message, catalogVerification: { status: 'unverified' } });
138 }
139 if (attempt + 1 < verifyAttempts) await sleep(500 * (attempt + 1));
140 }
141 }
142 }
143 await Promise.all(Array.from({ length: Math.min(4, pending.length) }, () => worker()));
144 return rows;
145}
146
147function correlateBulk(response, input) {
148 const bulk = require('./bulk-results');
149 const result = bulk.correlateBulk(response, input, bulk.adapters.inventory);
150 if (result.defects.length || result.unaccounted || result.undetailedFailures) {
151 throw new Error('Inventory bulk response has invalid correlation or does not account for every input');
152 }
153 return result.results.map(r => ({ row: r.input, success: r.success, error: r.raw.itemMetadata.error || null }));
154}
155
156function buildInventoryReport(rows, gaps = []) {
157 const counts = Object.fromEntries(OUTCOMES.map((key) => [key, 0]));
158 for (const row of rows) {
159 if (!OUTCOMES.includes(row.outcome)) throw new Error('Unknown inventory outcome');
160 counts[row.outcome]++;
161 }
162 counts['decision-needed'] = gaps.length;
163 return { rows, gaps, counts, intended: rows.length + gaps.length,
164 complete: rows.length > 0 && counts.failed === 0 && counts.unverified === 0 && gaps.length === 0,
165 status: rows.length + gaps.length === 0 ? 'no-data' : counts.failed || counts.unverified || gaps.length ? 'partial' : 'complete' };
166}
167
168async function persistInventoryReport(file, report) {
169 const fs = require('node:fs/promises');
170 const path = require('node:path');
171 const crypto = require('node:crypto');
172 await fs.mkdir(path.dirname(file), { recursive: true });
173 const temp = `${file}.${crypto.randomUUID()}.tmp`;
174 try {
175 await fs.writeFile(temp, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
176 await fs.rename(temp, file);
177 } finally { await fs.rm(temp, { force: true }); }
178}
179
180// Input IDs come from the product/variant crosswalk, including products created on earlier runs.
181// Query and verify even after inline product/inventory create; a product receipt isn't stock proof.
182async function reconcileInventory(wix, input, { locationId, gaps = [], expectedCount, verifyAttempts = 3, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
183 if (!Array.isArray(input) || !Array.isArray(gaps)) throw new Error('Inventory rows and gaps must be arrays');
184 if (!Number.isInteger(expectedCount) || expectedCount < 0 || expectedCount !== input.length + gaps.length) {
185 throw new Error('Inventory rows and gaps must account for the explicit source-variant expectedCount');
186 }
187 if (!Number.isInteger(verifyAttempts) || verifyAttempts < 1 || verifyAttempts > 5) throw new Error('verifyAttempts must be 1–5');
188 const seen = new Set();
189 const rows = input.map((row) => {
190 const productId = identity(row.productId, 'productId');
191 const variantId = identity(row.variantId, 'variantId');
192 if (seen.has(variantId)) throw new Error('Duplicate input variant identity');
193 seen.add(variantId);
194 return { productId, variantId, inventory: stockPayload(row.inventory), outcome: 'unverified' };
195 });
196 if (!rows.length) return buildInventoryReport(rows, gaps);
197 let resolved, isDefault;
198 try { const location = await resolveInventoryLocationDetails(wix, locationId); resolved = location.id; isDefault = location.defaultLocation; }
199 catch (error) { if (wixAuth.isRunInterruption(error)) throw error; return buildInventoryReport(rows.map((r) => ({ ...r, outcome: 'failed', error: error.message })), gaps); }
200 for (let start = 0; start < rows.length; start += BATCH_LIMIT) {
201 const batch = rows.slice(start, start + BATCH_LIMIT);
202 let existing;
203 try { existing = await readInventory(wix, batch, resolved); }
204 catch (error) { if (wixAuth.isRunInterruption(error)) throw error; for (const row of batch) Object.assign(row, { outcome: 'failed', error: error.message }); continue; }
205 const create = [], update = [];
206 for (const row of batch) {
207 row.locationId = resolved;
208 const item = existing.get(row.variantId);
209 if (item && item.preorderInfo && item.preorderInfo.enabled) {
210 Object.assign(row, { outcome: 'failed', error: 'Existing preorder policy requires a mapping decision' });
211 } else if (matchesInventory(item, row.inventory)) {
212 Object.assign(row, { outcome: 'reconciled', inventoryItemId: item.id });
213 } else if (!item) create.push(row);
214 else if (item.revision == null) Object.assign(row, { outcome: 'failed', error: 'Inventory update needs a current revision' });
215 else { row.inventoryItemId = item.id; row.revision = item.revision; update.push(row); }
216 }
217 for (const [operation, pending] of [['create', create], ['update', update]]) {
218 if (!pending.length) continue;
219 try {
220 const inventoryItems = pending.map((row) => operation === 'create'
221 ? { productId: row.productId, variantId: row.variantId, locationId: resolved, ...row.inventory }
222 : { inventoryItem: { id: row.inventoryItemId, revision: row.revision, ...row.inventory } });
223 const response = await wix.send(require('./wix-writers').buildBulkInventoryItemsRequest(operation, inventoryItems));
224 for (const result of correlateBulk(response, pending)) {
225 if (result.success) result.row.writeOutcome = operation === 'create' ? 'created' : 'updated';
226 else Object.assign(result.row, { outcome: 'failed', error: result.error || 'Inventory item write failed' });
227 }
228 } catch (error) { if (wixAuth.isRunInterruption(error)) throw error; for (const row of pending) Object.assign(row, { outcome: 'failed', error: error.message }); }
229 }
230 const written = batch.filter((r) => r.writeOutcome && r.outcome !== 'failed');
231 if (!written.length) continue;
232 for (let attempt = 0; attempt < verifyAttempts; attempt++) {
233 try {
234 const back = await readInventory(wix, written, resolved);
235 for (const row of written) {
236 const item = back.get(row.variantId);
237 if (matchesInventory(item, row.inventory)) { Object.assign(row, { outcome: row.writeOutcome, inventoryItemId: item.id }); delete row.error; delete row.inventoryVerification; }
238 else Object.assign(row, { outcome: 'unverified', error: item ? 'Inventory read-back differs from intended tracking, stock or availability' : 'Inventory item missing from read-back', inventoryVerification: { expected: row.inventory, actual: item ? { trackQuantity: item.trackQuantity, inStock: item.inStock, quantity: item.quantity, availabilityStatus: item.availabilityStatus } : null } });
239 }
240 if (written.every((r) => r.outcome !== 'unverified')) break;
241 } catch (error) { if (wixAuth.isRunInterruption(error)) throw error; for (const row of written) Object.assign(row, { outcome: 'unverified', error: error.message }); }
242 if (attempt + 1 < verifyAttempts) await sleep(250 * (attempt + 1));
243 }
244 }
245 if (isDefault) await verifyCatalogInventory(wix, rows, { verifyAttempts, sleep });
246 else for (const row of rows) row.catalogVerification = { status: 'not-applicable', reason: 'non-default-location' };
247 return buildInventoryReport(rows, gaps);
248}
249
250module.exports = { stockPayload, inventoryForVariant, resolveInventoryLocation, readInventory, matchesInventory, verifyCatalogInventory, correlateBulk, reconcileInventory, buildInventoryReport, persistInventoryReport, BATCH_LIMIT };