424 lines
12 KiB
JavaScript
Executable File
424 lines
12 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 API_VERSION = '2023-10';
|
|
const exportDir = path.join(__dirname, 'exports');
|
|
const JSONL_FILENAME = 'inventory_data.jsonl';
|
|
|
|
let SHOP, ACCESS_TOKEN, TURN14_ACCESS_TOKEN;
|
|
|
|
|
|
|
|
|
|
// ─── HELPERS ───────────────────────────────────────────────────────
|
|
|
|
function chunkArray(array, size) {
|
|
const result = [];
|
|
for (let i = 0; i < array.length; i += size) {
|
|
result.push(array.slice(i, i + size));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function timestamp() {
|
|
const now = new Date();
|
|
return now.toISOString().replace(/[-:]/g, '').replace('T', '_').split('.')[0];
|
|
}
|
|
|
|
// function logStep(step, message) {
|
|
// console.log(`[${step}] ${message}`);
|
|
// }
|
|
|
|
|
|
const logFilePath = path.join(__dirname, "logs", 'BulkInventorySyncJob.log');
|
|
|
|
function logStep(step, message) {
|
|
const logMessage = `[${new Date().toISOString()}] [${step}] ${message}`;
|
|
|
|
// Log to console
|
|
console.log(logMessage);
|
|
|
|
// Append to file
|
|
fs.appendFileSync(logFilePath, logMessage + '\n', 'utf8');
|
|
}
|
|
// ─── SHOPIFY GRAPHQL HELPERS ──────────────────────────────────────
|
|
|
|
|
|
|
|
async function getTurn14AccessTokenFromMetafield() {
|
|
// Step 1: Get credentials from metafield
|
|
|
|
const client = axios.create({
|
|
baseURL: `https://${SHOP}/admin/api/2024-01/graphql.json`,
|
|
headers: {
|
|
'X-Shopify-Access-Token': ACCESS_TOKEN,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
|
|
|
|
const query = `
|
|
{
|
|
shop {
|
|
id
|
|
metafield(namespace: "turn14", key: "credentials") {
|
|
value
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const gqlRes = await client.post('', { query });
|
|
|
|
const result = gqlRes.data;
|
|
const shopId = result?.data?.shop?.id;
|
|
const raw = result?.data?.shop?.metafield?.value;
|
|
|
|
if (!raw) {
|
|
throw new Error("❌ No Turn14 credentials found in Shopify metafield.");
|
|
}
|
|
|
|
let creds;
|
|
try {
|
|
creds = JSON.parse(raw);
|
|
} catch (err) {
|
|
console.error("❌ Failed to parse Turn14 metafield JSON:", err);
|
|
throw new Error("Malformed Turn14 credential metafield.");
|
|
}
|
|
|
|
const now = new Date();
|
|
const expiresAt = new Date(creds.expiresAt);
|
|
const isExpired = now > expiresAt;
|
|
|
|
if (!isExpired && creds.accessToken) {
|
|
return creds.accessToken;
|
|
}
|
|
|
|
// ⏰ Expired — refresh token from Turn14 API
|
|
const response = await axios.post("https://turn14.data4autos.com/v1/auth/token", {
|
|
grant_type: "client_credentials",
|
|
client_id: creds.clientId,
|
|
client_secret: creds.clientSecret,
|
|
}, {
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
|
|
const data = response.data;
|
|
|
|
if (response.status !== 200) {
|
|
console.error("❌ Failed to refresh Turn14 token:", data);
|
|
throw new Error(data.error || "Failed to refresh Turn14 token");
|
|
}
|
|
|
|
const newToken = data.access_token;
|
|
const newExpiresAt = new Date(Date.now() + 3600 * 1000).toISOString();
|
|
|
|
const newValue = JSON.stringify({
|
|
clientId: creds.clientId,
|
|
clientSecret: creds.clientSecret,
|
|
accessToken: newToken,
|
|
expiresAt: newExpiresAt,
|
|
}).replace(/"/g, '\\"');
|
|
|
|
// Step 3: Update metafield in Shopify
|
|
const mutation = `
|
|
mutation {
|
|
metafieldsSet(metafields: [
|
|
{
|
|
ownerId: "${shopId}"
|
|
namespace: "turn14"
|
|
key: "credentials"
|
|
type: "json"
|
|
value: "${newValue}"
|
|
}
|
|
]) {
|
|
userErrors {
|
|
field
|
|
message
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
|
|
const updateRes = await client.post('', { query: mutation });
|
|
const userErrors = updateRes.data?.data?.metafieldsSet?.userErrors;
|
|
if (userErrors && userErrors.length > 0) {
|
|
throw new Error(`Failed to update metafield: ${JSON.stringify(userErrors)}`);
|
|
}
|
|
|
|
return newToken;
|
|
}
|
|
|
|
async function getLocationID() {
|
|
logStep('3', 'Fetching Shopify location ID...');
|
|
|
|
const client = axios.create({
|
|
baseURL: `https://${SHOP}/admin/api/2024-01/graphql.json`,
|
|
headers: {
|
|
'X-Shopify-Access-Token': ACCESS_TOKEN,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
const response = await client.post('', {
|
|
query: `
|
|
query {
|
|
locations(first: 10) {
|
|
edges { node { id name } }
|
|
}
|
|
}
|
|
`,
|
|
});
|
|
|
|
const locations = response.data.data.locations.edges;
|
|
const locationId = locations[0].node.id;
|
|
logStep('3', `✅ Location ID: ${locationId}`);
|
|
return locationId;
|
|
}
|
|
|
|
async function startBulkExport() {
|
|
logStep('4.1', 'Starting Shopify bulk product 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));
|
|
|
|
logStep('4.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;
|
|
logStep('4.2', `Status: ${op.status}`);
|
|
if (op.status === 'COMPLETED') {
|
|
logStep('4.2', `✅ 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);
|
|
});
|
|
});
|
|
logStep('4.3', `✅ Downloaded export to ${fname}`);
|
|
return fname;
|
|
}
|
|
|
|
async function buildHandleMap(ndjsonPath, desiredInventoryByHandle) {
|
|
logStep('5', 'Building handle → inventory map...');
|
|
const handleMap = {};
|
|
const productIdToHandle = {};
|
|
|
|
const rl = readline.createInterface({
|
|
input: fs.createReadStream(ndjsonPath),
|
|
crlfDelay: Infinity
|
|
});
|
|
|
|
for await (const line of rl) {
|
|
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({ inventoryItemId: rec.inventoryItem.id });
|
|
}
|
|
}
|
|
}
|
|
|
|
logStep('5', `✅ Mapped ${Object.keys(handleMap).length} handles`);
|
|
return handleMap;
|
|
}
|
|
|
|
function writeBulkInventoryJSONL(handleMap, desiredInventoryByHandle, outputPath, locationId) {
|
|
logStep('6', 'Writing JSONL inventory updates...');
|
|
const stream = fs.createWriteStream(outputPath);
|
|
const updates = [];
|
|
|
|
for (const [handle, items] of Object.entries(handleMap)) {
|
|
const qty = desiredInventoryByHandle[handle];
|
|
if (qty == null) continue;
|
|
items.forEach(({ inventoryItemId }) => {
|
|
const entry = { inventoryItemId, quantity: qty, locationId };
|
|
updates.push(entry);
|
|
stream.write(JSON.stringify({ input: entry }) + '\n');
|
|
});
|
|
}
|
|
|
|
stream.end();
|
|
logStep('6', `✅ Wrote ${updates.length} inventory adjustments`);
|
|
return updates;
|
|
}
|
|
|
|
async function updateInventoryBatch(batch, index) {
|
|
logStep(`7.${index}`, 'Updating inventory batch...');
|
|
const mutation = `
|
|
mutation {
|
|
inventorySetQuantities(input: {
|
|
name: "available",
|
|
reason: "correction",
|
|
ignoreCompareQuantity: true,
|
|
quantities: [
|
|
${batch.map(item => `{
|
|
inventoryItemId: "${item.inventoryItemId}",
|
|
locationId: "${item.locationId}",
|
|
quantity: ${item.quantity}
|
|
}`).join(',\n')}
|
|
]
|
|
}) {
|
|
inventoryAdjustmentGroup { id }
|
|
userErrors { field message }
|
|
}
|
|
}
|
|
`;
|
|
|
|
try {
|
|
const res = await axios.post(
|
|
`https://${SHOP}/admin/api/2025-07/graphql.json`,
|
|
{ query: mutation },
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Shopify-Access-Token': ACCESS_TOKEN,
|
|
},
|
|
}
|
|
);
|
|
|
|
const json = res.data;
|
|
if (json.errors || json.data.inventorySetQuantities.userErrors.length) {
|
|
console.error(`[7.${index}] ❌ Errors:`, JSON.stringify(json.errors || json.data.inventorySetQuantities.userErrors));
|
|
} else {
|
|
logStep(`7.${index}`, '✅ Batch updated successfully');
|
|
}
|
|
} catch (err) {
|
|
console.error(`[7.${index}] ❌ Axios Error:`, err.response?.data || err.message);
|
|
}
|
|
}
|
|
|
|
// ─── MASTER FUNCTION ──────────────────────────────────────────────
|
|
|
|
async function syncTurn14Inventory(shop, accessToken) {
|
|
try {
|
|
|
|
|
|
SHOP = shop;
|
|
ACCESS_TOKEN = accessToken;
|
|
|
|
|
|
|
|
const token = await getTurn14AccessTokenFromMetafield()
|
|
.catch(err => {
|
|
console.error('Error:', err.message);
|
|
});
|
|
|
|
TURN14_ACCESS_TOKEN = token
|
|
logStep('0', '🔧 Starting syncTurn14Inventory...');
|
|
|
|
const BATCH_SIZE = 1000;
|
|
const desiredInventoryByHandle = {};
|
|
|
|
// Step 1: Fetch Turn14 data
|
|
logStep('1', 'Fetching Turn14 inventory...');
|
|
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();
|
|
turn14Data.forEach(item => {
|
|
desiredInventoryByHandle[item.id] = item.totalQuantity;
|
|
});
|
|
|
|
//desiredInventoryByHandle["358019"] = 4561
|
|
logStep('1', `✅ Loaded ${Object.keys(desiredInventoryByHandle).length} items from Turn14`);
|
|
// console.log(desiredInventoryByHandle)
|
|
// Step 2: Ensure export dir exists
|
|
if (!fs.existsSync(exportDir)) fs.mkdirSync(exportDir);
|
|
|
|
// Step 3-6: Shopify Export, Mapping & JSONL Write
|
|
const locationId = await getLocationID();
|
|
await startBulkExport();
|
|
const url = await pollUntilReady();
|
|
const ndjsonPath = await downloadToFile(url);
|
|
const handleMap = await buildHandleMap(ndjsonPath, desiredInventoryByHandle);
|
|
const jsonlPath = path.join(exportDir, JSONL_FILENAME);
|
|
const inventoryUpdates = writeBulkInventoryJSONL(handleMap, desiredInventoryByHandle, jsonlPath, locationId);
|
|
|
|
// Step 7: Update in batches
|
|
const batches = chunkArray(inventoryUpdates, BATCH_SIZE);
|
|
for (let i = 0; i < batches.length; i++) {
|
|
await updateInventoryBatch(batches[i], i + 1);
|
|
}
|
|
|
|
logStep('8', '✅ All inventory batches processed.');
|
|
} catch (err) {
|
|
logStep('🚨 ERROR', err.message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
export { syncTurn14Inventory };
|