343 lines
11 KiB
JavaScript
Executable File
343 lines
11 KiB
JavaScript
Executable File
import axios from 'axios';
|
|
import https from 'https';
|
|
import fs from 'fs';
|
|
import readline from 'readline';
|
|
import path from 'path';
|
|
import FormData from 'form-data';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
// ─── CONFIG ────────────────────────────────────────────────────────
|
|
const SHOP = 'veloxautomotive.myshopify.com';
|
|
const ACCESS_TOKEN = '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';
|
|
const API_VERSION = '2023-10';
|
|
const TURN14_ACCESS_TOKEN = '4d82c2d2a2ddb59a6da68afb722f50dd43f4640b';
|
|
const exportDir = path.join(__dirname, 'exports');
|
|
const JSONL_FILENAME = 'bulk_inventory.jsonl';
|
|
|
|
const desiredInventoryByHandle = {};
|
|
|
|
// ─── HELPERS ────────────────────────────────────────────────────────
|
|
function timestamp() {
|
|
const now = new Date();
|
|
return now.toISOString().replace(/[-:]/g, '').replace('T', '_').split('.')[0];
|
|
}
|
|
|
|
// ─── STAGE FILE UPLOAD ──────────────────────────────────────────────
|
|
async function getStagedUploadPath() {
|
|
const mutation = `
|
|
mutation {
|
|
stagedUploadsCreate(input:[{
|
|
resource: BULK_MUTATION_VARIABLES,
|
|
filename: "${JSONL_FILENAME}",
|
|
mimeType: "text/jsonl",
|
|
httpMethod: POST
|
|
}]){
|
|
stagedTargets {
|
|
url
|
|
resourceUrl
|
|
parameters { name value }
|
|
}
|
|
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 result = resp.data.data.stagedUploadsCreate;
|
|
|
|
if (result.userErrors.length) {
|
|
throw new Error('Staged upload error: ' + JSON.stringify(result.userErrors));
|
|
}
|
|
|
|
const target = result.stagedTargets[0];
|
|
const keyParam = target.parameters.find(p => p.name === 'key');
|
|
if (!keyParam) throw new Error('Missing "key" parameter in staged upload target');
|
|
|
|
// console.log("target",target)
|
|
return {
|
|
uploadUrl: target.url,
|
|
parameters: target.parameters,
|
|
stagedUploadPath: keyParam.value,
|
|
};
|
|
}
|
|
|
|
async function uploadJSONLFile(uploadUrl, params, filePath) {
|
|
const form = new FormData();
|
|
params.forEach(({ name, value }) => form.append(name, value));
|
|
form.append('file', fs.createReadStream(filePath));
|
|
|
|
await axios.post(uploadUrl, form, {
|
|
headers: form.getHeaders()
|
|
});
|
|
|
|
console.log('✅ Uploaded JSONL file to Shopify');
|
|
}
|
|
|
|
async function runBulkMutation(stagedUploadPath) {
|
|
|
|
console.log("Resource URL",stagedUploadPath)
|
|
const mutation = `
|
|
mutation bulkInventoryAdjust($input: InventoryAdjustQuantityInput!) {
|
|
inventoryAdjustQuantity(input: $input) {
|
|
inventoryLevel { id available }
|
|
userErrors { field message }
|
|
}
|
|
}
|
|
`;
|
|
|
|
const wrappedMutation = `
|
|
mutation {
|
|
bulkOperationRunMutation(
|
|
mutation: """${mutation}""",
|
|
stagedUploadPath: "${stagedUploadPath}"
|
|
) {
|
|
bulkOperation { id status }
|
|
userErrors { field message }
|
|
}
|
|
}
|
|
`;
|
|
|
|
const resp = await axios.post(
|
|
`https://${SHOP}/admin/api/${API_VERSION}/graphql.json`,
|
|
{ query: wrappedMutation },
|
|
{ headers: { 'X-Shopify-Access-Token': ACCESS_TOKEN } }
|
|
);
|
|
|
|
console.log('📦 Mutation Response:', JSON.stringify(resp.data, null, 2));
|
|
|
|
const result = resp.data.data?.bulkOperationRunMutation;
|
|
if (!result) throw new Error('bulkOperationRunMutation failed.');
|
|
|
|
if (result.userErrors.length) {
|
|
console.error('❌ Mutation Errors:', result.userErrors);
|
|
} else {
|
|
console.log('✅ Bulk inventory update started with ID:', result.bulkOperation.id);
|
|
}
|
|
}
|
|
|
|
// ─── EXPORT / NDJSON LOGIC ─────────────────────────────────────────
|
|
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) throw new Error(JSON.stringify(err));
|
|
|
|
console.log('[1] ✔️ Export started:', resp.data.data.bulkOperationRunQuery.bulkOperation.id);
|
|
}
|
|
|
|
async function pollUntilReady() {
|
|
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 export. File URL:', op.url);
|
|
return op.url;
|
|
}
|
|
if (op.status === 'FAILED') {
|
|
throw new Error('Bulk export failed: ' + op.errorCode);
|
|
}
|
|
|
|
await new Promise(r => setTimeout(r, 5000));
|
|
}
|
|
}
|
|
|
|
async function downloadToFile(fileUrl) {
|
|
const fname = path.join(exportDir, `${timestamp()}_bulk_export.ndjson`);
|
|
const fileStream = fs.createWriteStream(fname);
|
|
await new Promise((resolve, reject) => {
|
|
https.get(fileUrl, res => {
|
|
res.pipe(fileStream);
|
|
fileStream.on('finish', () => {
|
|
fileStream.close(resolve);
|
|
});
|
|
fileStream.on('error', reject);
|
|
});
|
|
});
|
|
console.log(`[3] ✔️ Downloaded export to ${fname}`);
|
|
return fname;
|
|
}
|
|
|
|
async function buildHandleMap(ndjsonPath) {
|
|
console.log('[4] Building handle→inventory map...');
|
|
const handleMap = {};
|
|
const productIdToHandle = {};
|
|
desiredInventoryByHandle["84485"] = 2000;
|
|
const rl = readline.createInterface({
|
|
input: fs.createReadStream(ndjsonPath),
|
|
crlfDelay: Infinity
|
|
});
|
|
|
|
for await (const line of rl) {
|
|
try {
|
|
const rec = JSON.parse(line);
|
|
if (rec.handle && rec.id) {
|
|
productIdToHandle[rec.id] = rec.handle;
|
|
}
|
|
if (rec.__parentId && rec.inventoryItem?.id) {
|
|
const handle = productIdToHandle[rec.__parentId];
|
|
if (handle && desiredInventoryByHandle[handle] != null) {
|
|
if (!handleMap[handle]) handleMap[handle] = [];
|
|
handleMap[handle].push({
|
|
variantId: rec.id,
|
|
inventoryItemId: rec.inventoryItem.id
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('⚠️ JSON parse error:', e.message);
|
|
}
|
|
}
|
|
|
|
console.log(`[4] ✔️ Matched ${Object.keys(handleMap).length} handles`);
|
|
const mapPath = path.join(exportDir, `${timestamp()}_handle_map.json`);
|
|
fs.writeFileSync(mapPath, JSON.stringify(handleMap, null, 2));
|
|
return handleMap;
|
|
}
|
|
|
|
// function writeBulkInventoryJSONL(handleMap, desiredInventoryByHandle, outputPath) {
|
|
// const stream = fs.createWriteStream(outputPath);
|
|
// let lineCount = 0;
|
|
|
|
// for (const [handle, items] of Object.entries(handleMap)) {
|
|
// const qty = desiredInventoryByHandle[handle];
|
|
// if (qty == null) continue;
|
|
|
|
// items.forEach(({ inventoryItemId }) => {
|
|
// stream.write(JSON.stringify({ input: { inventoryItemId, availableDelta: qty } }) + '\n');
|
|
// lineCount++;
|
|
// });
|
|
// }
|
|
|
|
// stream.end();
|
|
// console.log(`✅ JSONL: wrote ${lineCount} inventory adjustments to ${outputPath}`);
|
|
// }
|
|
|
|
|
|
|
|
function writeBulkInventoryJSONL(handleMap, desiredInventoryByHandle, outputPath, locationId) {
|
|
const stream = fs.createWriteStream(outputPath);
|
|
let lineCount = 0;
|
|
|
|
const inventoryUpdates = [];
|
|
|
|
for (const [handle, items] of Object.entries(handleMap)) {
|
|
const qty = desiredInventoryByHandle[handle];
|
|
if (qty == null) continue;
|
|
|
|
items.forEach(({ inventoryItemId }) => {
|
|
inventoryUpdates.push({
|
|
inventoryItemId,
|
|
availableDelta: qty,
|
|
locationId,
|
|
});
|
|
lineCount++;
|
|
});
|
|
}
|
|
|
|
// Write all entries to the JSONL stream
|
|
inventoryUpdates.forEach((entry) => {
|
|
stream.write(JSON.stringify({ input: entry }) + '\n');
|
|
});
|
|
|
|
stream.end();
|
|
console.log(`✅ JSONL: wrote ${lineCount} inventory adjustments to ${outputPath}`);
|
|
}
|
|
|
|
|
|
|
|
// ─── MAIN FLOW ─────────────────────────────────────────────────────
|
|
try {
|
|
const turn14Res = await fetch(`https://turn14.data4autos.com/v1/inventory/allupdates`, {
|
|
headers: {
|
|
Authorization: `Bearer ${TURN14_ACCESS_TOKEN}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
});
|
|
const turn14Data = await turn14Res.json();
|
|
|
|
for (const item of turn14Data) {
|
|
desiredInventoryByHandle[item.id] = item.totalQuantity;
|
|
}
|
|
|
|
console.log(`✅ Loaded ${Object.keys(desiredInventoryByHandle).length} inventory items from Turn14`);
|
|
|
|
if (!fs.existsSync(exportDir)) fs.mkdirSync(exportDir);
|
|
|
|
await startBulkExport();
|
|
const url = await pollUntilReady();
|
|
const ndjsonPath = await downloadToFile(url);
|
|
const handleMap = await buildHandleMap(ndjsonPath);
|
|
|
|
const jsonlPath = path.join(exportDir, JSONL_FILENAME);
|
|
|
|
console.log(handleMap)
|
|
// writeBulkInventoryJSONL(handleMap, desiredInventoryByHandle, jsonlPath);
|
|
|
|
// const { uploadUrl, stagedUploadPath, parameters } = await getStagedUploadPath();
|
|
|
|
// console.log()
|
|
// await uploadJSONLFile(uploadUrl, parameters, jsonlPath);
|
|
// await runBulkMutation(stagedUploadPath);
|
|
} catch (err) {
|
|
console.error('🚨 Error:', err);
|
|
process.exit(1);
|
|
}
|