/** * shopify-bulk-inventory-with-logs.js * * Fully automated: * 1. start export * 2. poll until ready * 3. download NDJSON (saved as ./exports/YYYYMMDD_HHMMSS_bulk_export.ndjson) * 4. parse & map handles→inventory items (saved as ./exports/YYYYMMDD_HHMMSS_handle_map.json) * 5. fire off the bulk inventory update */ const axios = require('axios'); const https = require('https'); const fs = require('fs'); const readline = require('readline'); const path = require('path'); // ─── CONFIG ───────────────────────────────────────────────────────────────────── const SHOP = 'veloxautomotive.myshopify.com'; const ACCESS_TOKEN = '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'; const API_VERSION = '2023-10'; const desiredInventoryByHandle = {}; // Ensure exports directory exists const exportDir = path.join(__dirname, 'exports'); if (!fs.existsSync(exportDir)) { console.log('Creating exports directory at', exportDir); fs.mkdirSync(exportDir); } // ─── HELPERS ──────────────────────────────────────────────────────────────────── function timestamp() { const now = new Date(); const yyyy = now.getFullYear(); const MM = String(now.getMonth() + 1).padStart(2, '0'); const dd = String(now.getDate()).padStart(2, '0'); const hh = String(now.getHours()).padStart(2, '0'); const mm = String(now.getMinutes()).padStart(2, '0'); const ss = String(now.getSeconds()).padStart(2, '0'); return `${yyyy}${MM}${dd}_${hh}${mm}${ss}`; } // ─── 1. START THE EXPORT ───────────────────────────────────────────────────────── async function startBulkExport() { console.log('[1] Starting bulk export…'); const mutation = ` mutation { bulkOperationRunQuery( query: """ { products { edges { node { id handle variants { edges { node { id inventoryItem { id } } } } } } } } """ ) { bulkOperation { id status } userErrors { field message } } } `; const resp = await axios.post( `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`, { query: mutation }, { headers: { 'X-Shopify-Access-Token': ACCESS_TOKEN } } ); const err = resp.data.data.bulkOperationRunQuery.userErrors; if (err.length) { console.error('[1] ❌ Errors starting export:', err); throw new Error('Error starting export'); } console.log('[1] ✔️ Export kicked off:', resp.data.data.bulkOperationRunQuery.bulkOperation); } // ─── 2. POLL FOR COMPLETION ─────────────────────────────────────────────────────── async function pollUntilReady() { console.log('[2] Polling for bulk-export completion…'); const query = `{ currentBulkOperation { status url errorCode } }`; while (true) { const resp = await axios.post( `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`, { query }, { headers: { 'X-Shopify-Access-Token': ACCESS_TOKEN } } ); const op = resp.data.data.currentBulkOperation; console.log(`[2] Status: ${op.status}`); if (op.status === 'COMPLETED') { console.log('[2] ✔️ Completed. URL:', op.url); return op.url; } if (op.status === 'FAILED') { console.error('[2] ❌ Failed with code:', op.errorCode); throw new Error('Bulk operation failed'); } // still in progress… await new Promise(r => setTimeout(r, 5000)); } } // ─── 3. DOWNLOAD TO FILE ───────────────────────────────────────────────────────── async function downloadToFile(fileUrl) { const fname = path.join(exportDir, `${timestamp()}_bulk_export.ndjson`); console.log('[3] Downloading export to', fname); const fileStream = fs.createWriteStream(fname); await new Promise((resolve, reject) => { https.get(fileUrl, res => { res.pipe(fileStream); fileStream.on('finish', () => { console.log('[3] ✔️ Download complete'); fileStream.close(resolve); }); fileStream.on('error', err => { console.error('[3] ❌ Download error:', err); reject(err); }); }); }); return fname; } // ─── 4. PARSE & MAP HANDLES → INVENTORY ITEM IDS ───────────────────────────────── async function buildHandleMap(ndjsonPath) { console.log('[4] Parsing NDJSON and building handle map…'); const handleMap = {}; let totalLines = 0; const rl = readline.createInterface({ input: fs.createReadStream(ndjsonPath), crlfDelay: Infinity }); for await (const line of rl) { totalLines++; try { const rec = JSON.parse(line); const h = rec.handle; if (desiredInventoryByHandle.hasOwnProperty(h)) { handleMap[h] = rec.variants.edges.map(v => ({ variantId: v.node.id, inventoryItemId: v.node.inventoryItem.id })); } } catch (e) { console.error('[4] ⚠️ JSON parse error on line', totalLines, e); } } console.log(`[4] ✔️ Parsed ${totalLines} lines. Matched handles:`, Object.keys(handleMap)); // also save the map to disk const mapPath = path.join(exportDir, `${timestamp()}_handle_map.json`); fs.writeFileSync(mapPath, JSON.stringify(handleMap, null, 2)); console.log('[4] ✔️ Saved handle→inventory map to', mapPath); return handleMap; } // ─── 5. PREPARE & RUN BULK UPDATE MUTATION ──────────────────────────────────────── function makeBulkUpdateMutation(handleMap) { console.log('[5] Building bulk update mutation…'); const ops = []; for (const [handle, items] of Object.entries(handleMap)) { const qty = desiredInventoryByHandle[handle]; items.forEach(({ inventoryItemId }) => { ops.push(` inventoryAdjustQuantity( input: {inventoryItemId: "${inventoryItemId}", availableDelta: ${qty}} ) { inventoryLevel { id available } userErrors { field message } } `); }); } const full = ` mutation { bulkOperationRunMutation( mutation: """ mutation { ${ops.join('\n')} } """ ) { bulkOperation { id status } userErrors { field message } } } `; console.log('[5] ✔️ Mutation built with', ops.length, 'operations'); return full; } async function fetchAllLocations(pageSize = 250) { const endpoint = `https://${SHOP}/admin/api/2023-10/graphql.json`; let hasNextPage = true; let cursor = null; const allLocations = []; while (hasNextPage) { const query = ` query ($first: Int!, $after: String) { locations(first: $first, after: $after) { pageInfo { hasNextPage } edges { cursor node { id name } } } } `; const variables = { first: pageSize, after: cursor }; console.log('Fetching locations page, after cursor:', cursor); const resp = await axios.post( endpoint, { query, variables }, { headers: { 'X-Shopify-Access-Token': ACCESS_TOKEN } } ); const locs = resp.data.data.locations; locs.edges.forEach(edge => { allLocations.push(edge.node); }); hasNextPage = locs.pageInfo.hasNextPage; cursor = hasNextPage ? locs.edges[locs.edges.length - 1].cursor : null; } console.log(allLocations) console.log(`✅ Retrieved ${allLocations.length} locations in total`); return allLocations; } // ─── MAIN FLOW ──────────────────────────────────────────────────────────────────── (async function main() { try { await fetchAllLocations(20) await startBulkExport(); const url = await pollUntilReady(); const ndjsonPath = await downloadToFile(url); const handleMap = await buildHandleMap(ndjsonPath); // Now fire off the inventory update const mutation = makeBulkUpdateMutation(handleMap); console.log('[5] Kicking off inventory update…'); const resp = await axios.post( `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`, { query: mutation }, { headers: { 'X-Shopify-Access-Token': ACCESS_TOKEN } } ); const errs = resp.data.data.bulkOperationRunMutation.userErrors; if (errs.length) { console.error('[5] ❌ Inventory bulk update errors:', errs); } else { console.log('[5] ✔️ Inventory bulk update kickstarted successfully'); } } catch (err) { console.error('🚨 Uncaught error in automation:', err); process.exit(1); } })();