308 lines
9.8 KiB
JavaScript
Executable File
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);
|
|
}
|
|
})();
|