2026-04-13 05:23:25 +00:00

308 lines
9.8 KiB
JavaScript
Executable File

/**
* 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);
}
})();