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 = '2025-10'; const API_VERSION_25_10 = '2025-10'; const API_VERSION = '2025-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/2025-10/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."); } console.log("Turn14 Credentials fetched from metafield:", creds); const now = new Date(); const expiresAt = new Date(creds.expiresAt); const isExpired = now > expiresAt; if (!isExpired && creds.accessToken && false) { console.log("Turn14 token is still valid."); return creds.accessToken; } console.log("Turn14 token has expired or is missing. Refreshing..."); // ⏰ 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" }, }); console.log("Turn14 token refresh response status:", response.status); 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)}`); } console.log("✅ Turn14 token refreshed and metafield updated."); return newToken; } async function getLocationID() { logStep('3', 'Fetching Shopify location ID...'); const client = axios.create({ baseURL: `https://${SHOP}/admin/api/${API_VERSION}/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 mutation = ` mutation { bulkOperationRunQuery( query: """ { products { edges { node { id handle variants { edges { node { id inventoryItem { id } } } } } } } } """ ) { bulkOperation { id status } userErrors { field message code } } } `; 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); if (!fileUrl) { // 🟡 Create empty NDJSON file instead of downloading await fs.promises.writeFile(fname, '', 'utf8'); logStep('4.3', `⚠️ No file URL provided. Created empty export file at ${fname}`); return 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, FULFILLMENTSERVICEID, LOCATIONID) { try { SHOP = shop; ACCESS_TOKEN = accessToken; const token = await getTurn14AccessTokenFromMetafield() .catch(err => { console.error('Error fetching Turn14 token:', 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', }, }); console.log(`Turn14 API response status: ${turn14Res.status}`); 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(); const locationId = LOCATIONID || await getLocationID(); logStep('2', `✅ Using location ID: ${LOCATIONID}`); 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 in process', err.message); process.exit(1); } } export { syncTurn14Inventory };