fix: resolve inventory sync crash loop and parallel execution issues
- Remove process.exit(1) that caused PM2 to restart in an infinite loop - Fix && false bug that forced Turn14 token refresh on every run - Remove global SHOP/ACCESS_TOKEN to eliminate race conditions - Make shop loop sequential (for...of + await) to prevent Turn14 429s - Add early return when Turn14 credentials are missing for a shop - Guard non-JSON Turn14 responses (429 plain text) before calling .json() - Ensure logs/ and exports/ dirs exist before writing - Pass shop/accessToken as params to all helper functions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9b5e16b1c1
commit
c2b5168a04
@ -3,7 +3,6 @@ import https from 'https';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import readline from 'readline';
|
import readline from 'readline';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import FormData from 'form-data';
|
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@ -11,19 +10,19 @@ const __dirname = path.dirname(__filename);
|
|||||||
|
|
||||||
// ─── CONFIG ────────────────────────────────────────────────────────
|
// ─── CONFIG ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
//const API_VERSION = '2025-10';
|
|
||||||
const API_VERSION_25_10 = '2025-10';
|
|
||||||
const API_VERSION = '2025-10';
|
const API_VERSION = '2025-10';
|
||||||
const exportDir = path.join(__dirname, '..', 'exports');
|
const exportDir = path.join(__dirname, '..', 'exports');
|
||||||
|
const logDir = path.join(__dirname, '..', 'logs');
|
||||||
|
const logFilePath = path.join(logDir, 'BulkInventorySyncJob.log');
|
||||||
const JSONL_FILENAME = 'inventory_data.jsonl';
|
const JSONL_FILENAME = 'inventory_data.jsonl';
|
||||||
|
const BATCH_SIZE = 1000;
|
||||||
let SHOP, ACCESS_TOKEN, TURN14_ACCESS_TOKEN;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ─── HELPERS ───────────────────────────────────────────────────────
|
// ─── HELPERS ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ensureDir(dir) {
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
function chunkArray(array, size) {
|
function chunkArray(array, size) {
|
||||||
const result = [];
|
const result = [];
|
||||||
for (let i = 0; i < array.length; i += size) {
|
for (let i = 0; i < array.length; i += size) {
|
||||||
@ -33,104 +32,80 @@ function chunkArray(array, size) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function timestamp() {
|
function timestamp() {
|
||||||
const now = new Date();
|
return new Date().toISOString().replace(/[-:]/g, '').replace('T', '_').split('.')[0];
|
||||||
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) {
|
function logStep(step, message) {
|
||||||
|
ensureDir(logDir);
|
||||||
const logMessage = `[${new Date().toISOString()}] [${step}] ${message}`;
|
const logMessage = `[${new Date().toISOString()}] [${step}] ${message}`;
|
||||||
|
|
||||||
// Log to console
|
|
||||||
console.log(logMessage);
|
console.log(logMessage);
|
||||||
|
|
||||||
// Append to file
|
|
||||||
fs.appendFileSync(logFilePath, logMessage + '\n', 'utf8');
|
fs.appendFileSync(logFilePath, logMessage + '\n', 'utf8');
|
||||||
}
|
}
|
||||||
// ─── SHOPIFY GRAPHQL HELPERS ──────────────────────────────────────
|
|
||||||
|
|
||||||
|
// ─── TURN14 TOKEN ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getTurn14AccessToken(shop, accessToken) {
|
||||||
async function getTurn14AccessTokenFromMetafield() {
|
|
||||||
// Step 1: Get credentials from metafield
|
|
||||||
|
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL: `https://${SHOP}/admin/api/2025-10/graphql.json`,
|
baseURL: `https://${shop}/admin/api/${API_VERSION}/graphql.json`,
|
||||||
headers: {
|
headers: {
|
||||||
'X-Shopify-Access-Token': ACCESS_TOKEN,
|
'X-Shopify-Access-Token': accessToken,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const query = `{
|
||||||
|
shop {
|
||||||
const query = `
|
id
|
||||||
{
|
metafield(namespace: "turn14", key: "credentials") {
|
||||||
shop {
|
value
|
||||||
id
|
|
||||||
metafield(namespace: "turn14", key: "credentials") {
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
}`;
|
||||||
|
|
||||||
const gqlRes = await client.post('', { query });
|
const gqlRes = await client.post('', { query });
|
||||||
|
const shopId = gqlRes.data?.data?.shop?.id;
|
||||||
const result = gqlRes.data;
|
const raw = gqlRes.data?.data?.shop?.metafield?.value;
|
||||||
const shopId = result?.data?.shop?.id;
|
|
||||||
const raw = result?.data?.shop?.metafield?.value;
|
|
||||||
|
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
throw new Error("❌ No Turn14 credentials found in Shopify metafield.");
|
throw new Error('No Turn14 credentials found in Shopify metafield.');
|
||||||
}
|
}
|
||||||
|
|
||||||
let creds;
|
let creds;
|
||||||
try {
|
try {
|
||||||
creds = JSON.parse(raw);
|
creds = JSON.parse(raw);
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error("❌ Failed to parse Turn14 metafield JSON:", err);
|
throw new Error('Malformed Turn14 credential metafield.');
|
||||||
throw new Error("Malformed Turn14 credential metafield.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Turn14 Credentials fetched from metafield:", creds);
|
logStep('1', `Turn14 credentials loaded from metafield (clientId: ${creds.clientId?.slice(0, 8)}...)`);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const expiresAt = new Date(creds.expiresAt);
|
const expiresAt = creds.expiresAt ? new Date(creds.expiresAt) : new Date(0);
|
||||||
const isExpired = now > expiresAt;
|
const isExpired = now >= expiresAt;
|
||||||
|
|
||||||
if (!isExpired && creds.accessToken && false) {
|
if (!isExpired && creds.accessToken) {
|
||||||
console.log("Turn14 token is still valid.");
|
logStep('1', 'Turn14 token is still valid, using cached token.');
|
||||||
return creds.accessToken;
|
return creds.accessToken;
|
||||||
}
|
}
|
||||||
console.log("Turn14 token has expired or is missing. Refreshing...");
|
|
||||||
|
|
||||||
// ⏰ Expired — refresh token from Turn14 API
|
logStep('1', 'Turn14 token expired or missing. Refreshing...');
|
||||||
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 response = await axios.post(
|
||||||
const data = response.data;
|
'https://turn14.data4autos.com/v1/auth/token',
|
||||||
|
{
|
||||||
|
grant_type: 'client_credentials',
|
||||||
|
client_id: creds.clientId,
|
||||||
|
client_secret: creds.clientSecret,
|
||||||
|
},
|
||||||
|
{ headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
|
||||||
if (response.status !== 200) {
|
const newToken = response.data.access_token;
|
||||||
console.error("❌ Failed to refresh Turn14 token:", data);
|
if (!newToken) {
|
||||||
throw new Error(data.error || "Failed to refresh Turn14 token");
|
throw new Error('Turn14 token refresh returned no access_token.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const newToken = data.access_token;
|
|
||||||
const newExpiresAt = new Date(Date.now() + 3600 * 1000).toISOString();
|
const newExpiresAt = new Date(Date.now() + 3600 * 1000).toISOString();
|
||||||
|
|
||||||
const newValue = JSON.stringify({
|
const newValue = JSON.stringify({
|
||||||
clientId: creds.clientId,
|
clientId: creds.clientId,
|
||||||
clientSecret: creds.clientSecret,
|
clientSecret: creds.clientSecret,
|
||||||
@ -138,95 +113,47 @@ async function getTurn14AccessTokenFromMetafield() {
|
|||||||
expiresAt: newExpiresAt,
|
expiresAt: newExpiresAt,
|
||||||
}).replace(/"/g, '\\"');
|
}).replace(/"/g, '\\"');
|
||||||
|
|
||||||
// Step 3: Update metafield in Shopify
|
|
||||||
const mutation = `
|
const mutation = `
|
||||||
mutation {
|
mutation {
|
||||||
metafieldsSet(metafields: [
|
metafieldsSet(metafields: [{
|
||||||
{
|
ownerId: "${shopId}"
|
||||||
ownerId: "${shopId}"
|
namespace: "turn14"
|
||||||
namespace: "turn14"
|
key: "credentials"
|
||||||
key: "credentials"
|
type: "json"
|
||||||
type: "json"
|
value: "${newValue}"
|
||||||
value: "${newValue}"
|
}]) {
|
||||||
}
|
userErrors { field message }
|
||||||
]) {
|
|
||||||
userErrors {
|
|
||||||
field
|
|
||||||
message
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const updateRes = await client.post('', { query: mutation });
|
const updateRes = await client.post('', { query: mutation });
|
||||||
const userErrors = updateRes.data?.data?.metafieldsSet?.userErrors;
|
const userErrors = updateRes.data?.data?.metafieldsSet?.userErrors;
|
||||||
if (userErrors && userErrors.length > 0) {
|
if (userErrors?.length) {
|
||||||
throw new Error(`Failed to update metafield: ${JSON.stringify(userErrors)}`);
|
throw new Error(`Failed to update metafield: ${JSON.stringify(userErrors)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ Turn14 token refreshed and metafield updated.");
|
logStep('1', '✅ Turn14 token refreshed and metafield updated.');
|
||||||
return newToken;
|
return newToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLocationID() {
|
// ─── SHOPIFY HELPERS ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function getLocationID(shop, accessToken) {
|
||||||
logStep('3', 'Fetching Shopify location ID...');
|
logStep('3', 'Fetching Shopify location ID...');
|
||||||
|
const response = await axios.post(
|
||||||
const client = axios.create({
|
`https://${shop}/admin/api/${API_VERSION}/graphql.json`,
|
||||||
baseURL: `https://${SHOP}/admin/api/${API_VERSION}/graphql.json`,
|
{ query: `{ locations(first: 10) { edges { node { id name } } } }` },
|
||||||
headers: {
|
{ headers: { 'X-Shopify-Access-Token': accessToken } }
|
||||||
'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 locations = response.data.data.locations.edges;
|
||||||
const locationId = locations[0].node.id;
|
const locationId = locations[0].node.id;
|
||||||
logStep('3', `✅ Location ID: ${locationId}`);
|
logStep('3', `✅ Location ID: ${locationId}`);
|
||||||
return locationId;
|
return locationId;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startBulkExport() {
|
async function startBulkExport(shop, accessToken) {
|
||||||
logStep('4.1', 'Starting Shopify bulk product export...');
|
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 = `
|
const mutation = `
|
||||||
mutation {
|
mutation {
|
||||||
bulkOperationRunQuery(
|
bulkOperationRunQuery(
|
||||||
@ -252,43 +179,35 @@ async function startBulkExport() {
|
|||||||
"""
|
"""
|
||||||
) {
|
) {
|
||||||
bulkOperation { id status }
|
bulkOperation { id status }
|
||||||
userErrors {
|
userErrors { field message code }
|
||||||
field
|
|
||||||
message
|
|
||||||
code
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const resp = await axios.post(
|
const resp = await axios.post(
|
||||||
`https://${SHOP}/admin/api/${API_VERSION}/graphql.json`,
|
`https://${shop}/admin/api/${API_VERSION}/graphql.json`,
|
||||||
{ query: mutation },
|
{ query: mutation },
|
||||||
{ headers: { 'X-Shopify-Access-Token': ACCESS_TOKEN } }
|
{ headers: { 'X-Shopify-Access-Token': accessToken } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const err = resp.data.data.bulkOperationRunQuery.userErrors;
|
const err = resp.data.data.bulkOperationRunQuery.userErrors;
|
||||||
if (err.length) throw new Error(JSON.stringify(err));
|
if (err.length) throw new Error(JSON.stringify(err));
|
||||||
|
|
||||||
logStep('4.1', `✅ Export started: ${resp.data.data.bulkOperationRunQuery.bulkOperation.id}`);
|
logStep('4.1', `✅ Export started: ${resp.data.data.bulkOperationRunQuery.bulkOperation.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollUntilReady() {
|
async function pollUntilReady(shop, accessToken) {
|
||||||
const query = `{ currentBulkOperation { status url errorCode } }`;
|
const query = `{ currentBulkOperation { status url errorCode } }`;
|
||||||
while (true) {
|
while (true) {
|
||||||
const resp = await axios.post(
|
const resp = await axios.post(
|
||||||
`https://${SHOP}/admin/api/${API_VERSION}/graphql.json`,
|
`https://${shop}/admin/api/${API_VERSION}/graphql.json`,
|
||||||
{ query },
|
{ query },
|
||||||
{ headers: { 'X-Shopify-Access-Token': ACCESS_TOKEN } }
|
{ headers: { 'X-Shopify-Access-Token': accessToken } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
const op = resp.data.data.currentBulkOperation;
|
const op = resp.data.data.currentBulkOperation;
|
||||||
logStep('4.2', `Status: ${op.status}`);
|
logStep('4.2', `Bulk operation status: ${op.status}`);
|
||||||
if (op.status === 'COMPLETED') {
|
if (op.status === 'COMPLETED') {
|
||||||
logStep('4.2', `✅ File URL: ${op.url}`);
|
logStep('4.2', `✅ Export URL ready`);
|
||||||
return op.url;
|
return op.url;
|
||||||
}
|
}
|
||||||
if (op.status === 'FAILED') throw new Error(`Bulk export failed: ${op.errorCode}`);
|
if (op.status === 'FAILED') throw new Error(`Bulk export failed: ${op.errorCode}`);
|
||||||
@ -297,16 +216,16 @@ async function pollUntilReady() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadToFile(fileUrl) {
|
async function downloadToFile(fileUrl) {
|
||||||
|
ensureDir(exportDir);
|
||||||
const fname = path.join(exportDir, `${timestamp()}_bulk_export.ndjson`);
|
const fname = path.join(exportDir, `${timestamp()}_bulk_export.ndjson`);
|
||||||
const fileStream = fs.createWriteStream(fname);
|
|
||||||
|
|
||||||
if (!fileUrl) {
|
if (!fileUrl) {
|
||||||
// 🟡 Create empty NDJSON file instead of downloading
|
|
||||||
await fs.promises.writeFile(fname, '', 'utf8');
|
await fs.promises.writeFile(fname, '', 'utf8');
|
||||||
logStep('4.3', `⚠️ No file URL provided. Created empty export file at ${fname}`);
|
logStep('4.3', `⚠️ No file URL — created empty export file`);
|
||||||
return fname;
|
return fname;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileStream = fs.createWriteStream(fname);
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
https.get(fileUrl, res => {
|
https.get(fileUrl, res => {
|
||||||
res.pipe(fileStream);
|
res.pipe(fileStream);
|
||||||
@ -325,7 +244,7 @@ async function buildHandleMap(ndjsonPath, desiredInventoryByHandle) {
|
|||||||
|
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: fs.createReadStream(ndjsonPath),
|
input: fs.createReadStream(ndjsonPath),
|
||||||
crlfDelay: Infinity
|
crlfDelay: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
@ -364,8 +283,8 @@ function writeBulkInventoryJSONL(handleMap, desiredInventoryByHandle, outputPath
|
|||||||
return updates;
|
return updates;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateInventoryBatch(batch, index) {
|
async function updateInventoryBatch(batch, index, shop, accessToken) {
|
||||||
logStep(`7.${index}`, 'Updating inventory batch...');
|
logStep(`7.${index}`, `Updating inventory batch of ${batch.length} items...`);
|
||||||
const mutation = `
|
const mutation = `
|
||||||
mutation {
|
mutation {
|
||||||
inventorySetQuantities(input: {
|
inventorySetQuantities(input: {
|
||||||
@ -388,78 +307,82 @@ async function updateInventoryBatch(batch, index) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await axios.post(
|
const res = await axios.post(
|
||||||
`https://${SHOP}/admin/api/2025-07/graphql.json`,
|
`https://${shop}/admin/api/2025-07/graphql.json`,
|
||||||
{ query: mutation },
|
{ query: mutation },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-Shopify-Access-Token': ACCESS_TOKEN,
|
'X-Shopify-Access-Token': accessToken,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const json = res.data;
|
const json = res.data;
|
||||||
if (json.errors || json.data.inventorySetQuantities.userErrors.length) {
|
const userErrors = json.data?.inventorySetQuantities?.userErrors;
|
||||||
console.error(`[7.${index}] ❌ Errors:`, JSON.stringify(json.errors || json.data.inventorySetQuantities.userErrors));
|
if (json.errors || userErrors?.length) {
|
||||||
|
console.error(`[7.${index}] ❌ Errors:`, JSON.stringify(json.errors || userErrors));
|
||||||
} else {
|
} else {
|
||||||
logStep(`7.${index}`, '✅ Batch updated successfully');
|
logStep(`7.${index}`, '✅ Batch updated successfully');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[7.${index}] ❌ Axios Error:`, err.response?.data || err.message);
|
console.error(`[7.${index}] ❌ Axios error:`, err.response?.data || err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── MASTER FUNCTION ──────────────────────────────────────────────
|
// ─── MASTER FUNCTION ──────────────────────────────────────────────
|
||||||
|
|
||||||
async function syncTurn14Inventory(shop, accessToken, FULFILLMENTSERVICEID, LOCATIONID) {
|
async function syncTurn14Inventory(shop, accessToken, FULFILLMENTSERVICEID, LOCATIONID) {
|
||||||
|
logStep('0', `🔧 Starting syncTurn14Inventory for ${shop}...`);
|
||||||
|
|
||||||
|
// Step 1: Get Turn14 token — skip shop if credentials are missing
|
||||||
|
let turn14Token;
|
||||||
try {
|
try {
|
||||||
|
turn14Token = await getTurn14AccessToken(shop, accessToken);
|
||||||
|
} catch (err) {
|
||||||
|
logStep('1', `⚠️ Skipping ${shop} — could not get Turn14 token: ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!turn14Token) {
|
||||||
|
logStep('1', `⚠️ Skipping ${shop} — Turn14 token is empty.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
SHOP = shop;
|
try {
|
||||||
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 = {};
|
const desiredInventoryByHandle = {};
|
||||||
|
|
||||||
// Step 1: Fetch Turn14 data
|
// Step 1: Fetch Turn14 inventory
|
||||||
logStep('1', 'Fetching Turn14 inventory...');
|
logStep('1', 'Fetching Turn14 inventory...');
|
||||||
const turn14Res = await fetch('https://turn14.data4autos.com/v1/inventory/allupdates', {
|
const turn14Res = await fetch('https://turn14.data4autos.com/v1/inventory/allupdates', {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${TURN14_ACCESS_TOKEN}`,
|
Authorization: `Bearer ${turn14Token}`,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(`Turn14 API response status: ${turn14Res.status}`);
|
|
||||||
|
logStep('1', `Turn14 API response status: ${turn14Res.status}`);
|
||||||
|
|
||||||
|
if (!turn14Res.ok) {
|
||||||
|
const body = await turn14Res.text();
|
||||||
|
throw new Error(`Turn14 inventory fetch failed (${turn14Res.status}): ${body.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
const turn14Data = await turn14Res.json();
|
const turn14Data = await turn14Res.json();
|
||||||
turn14Data.forEach(item => {
|
turn14Data.forEach(item => {
|
||||||
desiredInventoryByHandle[item.id] = item.totalQuantity;
|
desiredInventoryByHandle[item.id] = item.totalQuantity;
|
||||||
});
|
});
|
||||||
|
|
||||||
//desiredInventoryByHandle["358019"] = 4561
|
|
||||||
logStep('1', `✅ Loaded ${Object.keys(desiredInventoryByHandle).length} items from Turn14`);
|
logStep('1', `✅ Loaded ${Object.keys(desiredInventoryByHandle).length} items from Turn14`);
|
||||||
// console.log(desiredInventoryByHandle)
|
|
||||||
// Step 2: Ensure export dir exists
|
// Step 2: Ensure export dir exists
|
||||||
if (!fs.existsSync(exportDir)) fs.mkdirSync(exportDir);
|
ensureDir(exportDir);
|
||||||
|
|
||||||
// Step 3-6: Shopify Export, Mapping & JSONL Write
|
// Step 3: Get location ID
|
||||||
//const locationId = await getLocationID();
|
const locationId = LOCATIONID || await getLocationID(shop, accessToken);
|
||||||
|
logStep('2', `✅ Using location ID: ${locationId}`);
|
||||||
|
|
||||||
const locationId = LOCATIONID || await getLocationID();
|
// Steps 4-6: Shopify bulk export, mapping, JSONL
|
||||||
logStep('2', `✅ Using location ID: ${LOCATIONID}`);
|
await startBulkExport(shop, accessToken);
|
||||||
|
const url = await pollUntilReady(shop, accessToken);
|
||||||
await startBulkExport();
|
|
||||||
const url = await pollUntilReady();
|
|
||||||
const ndjsonPath = await downloadToFile(url);
|
const ndjsonPath = await downloadToFile(url);
|
||||||
const handleMap = await buildHandleMap(ndjsonPath, desiredInventoryByHandle);
|
const handleMap = await buildHandleMap(ndjsonPath, desiredInventoryByHandle);
|
||||||
const jsonlPath = path.join(exportDir, JSONL_FILENAME);
|
const jsonlPath = path.join(exportDir, JSONL_FILENAME);
|
||||||
@ -468,13 +391,13 @@ async function syncTurn14Inventory(shop, accessToken, FULFILLMENTSERVICEID, LOCA
|
|||||||
// Step 7: Update in batches
|
// Step 7: Update in batches
|
||||||
const batches = chunkArray(inventoryUpdates, BATCH_SIZE);
|
const batches = chunkArray(inventoryUpdates, BATCH_SIZE);
|
||||||
for (let i = 0; i < batches.length; i++) {
|
for (let i = 0; i < batches.length; i++) {
|
||||||
await updateInventoryBatch(batches[i], i + 1);
|
await updateInventoryBatch(batches[i], i + 1, shop, accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
logStep('8', '✅ All inventory batches processed.');
|
logStep('8', `✅ Inventory sync complete for ${shop}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logStep('🚨 ERROR in process', err.message);
|
logStep('🚨 ERROR', `Sync failed for ${shop}: ${err.message}`);
|
||||||
process.exit(1);
|
// Do NOT process.exit — let the next shop continue and PM2 stay alive
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,42 +3,59 @@ const path = require('path');
|
|||||||
const { syncTurn14Inventory } = require('./InventorySync');
|
const { syncTurn14Inventory } = require('./InventorySync');
|
||||||
|
|
||||||
const filePath = path.join(__dirname, '..', 'data', 'tokens.json');
|
const filePath = path.join(__dirname, '..', 'data', 'tokens.json');
|
||||||
const logFilePath = path.join(__dirname, '..', 'logs', 'BulkInventorySyncJob.log');
|
const logDir = path.join(__dirname, '..', 'logs');
|
||||||
|
const logFilePath = path.join(logDir, 'BulkInventorySyncJob.log');
|
||||||
|
|
||||||
|
function ensureDir(dir) {
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
function logStep(step, message) {
|
function logStep(step, message) {
|
||||||
const logMessage = `[${new Date().toISOString()}] [${step}] ${message}`;
|
ensureDir(logDir);
|
||||||
console.log(logMessage);
|
const logMessage = `[${new Date().toISOString()}] [${step}] ${message}`;
|
||||||
fs.appendFileSync(logFilePath, logMessage + '\n', 'utf8');
|
console.log(logMessage);
|
||||||
|
fs.appendFileSync(logFilePath, logMessage + '\n', 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
function runBulkInventorySyncJob() {
|
async function runBulkInventorySyncJob() {
|
||||||
logStep('Bulk Caller Job', `JOB STARTED`);
|
logStep('Bulk Caller Job', 'JOB STARTED');
|
||||||
const jsonData = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
||||||
|
|
||||||
Object.entries(jsonData).forEach(([shopDomain, details]) => {
|
const jsonData = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||||
const SHOP = shopDomain.trim();
|
const shops = Object.entries(jsonData);
|
||||||
const ACCESS_TOKEN = details.accessToken;
|
|
||||||
|
|
||||||
const fulfillmentServiceTokens = details.fulfillmentService || {}
|
// Process shops sequentially to avoid hammering Turn14 with parallel requests
|
||||||
const FULFILLMENTSERVICEID = fulfillmentServiceTokens.id || null;
|
for (const [shopDomain, details] of shops) {
|
||||||
// const LOCATIONID = fulfillmentServiceTokens.location ? fulfillmentServiceTokens.location.id : null;
|
const SHOP = shopDomain.trim();
|
||||||
const LOCATIONID = details.locationId ? details.locationId : null;
|
const ACCESS_TOKEN = details.accessToken;
|
||||||
|
const fulfillmentServiceTokens = details.fulfillmentService || {};
|
||||||
|
const FULFILLMENTSERVICEID = fulfillmentServiceTokens.id || null;
|
||||||
|
const LOCATIONID = details.locationId || null;
|
||||||
|
|
||||||
console.log("Location ID : ", LOCATIONID)
|
logStep('Bulk Caller Job', `Syncing inventory for: ${SHOP} (locationId: ${LOCATIONID})`);
|
||||||
|
|
||||||
logStep('Bulk Caller Job', `Syncing inventory for: ${SHOP}`);
|
try {
|
||||||
syncTurn14Inventory(SHOP, ACCESS_TOKEN, FULFILLMENTSERVICEID, LOCATIONID);
|
await syncTurn14Inventory(SHOP, ACCESS_TOKEN, FULFILLMENTSERVICEID, LOCATIONID);
|
||||||
|
} catch (err) {
|
||||||
|
logStep('Bulk Caller Job', `❌ Unexpected error for ${SHOP}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logStep('Bulk Caller Job', 'JOB COMPLETED');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule: every 3 hours and 50 minutes
|
||||||
|
const INTERVAL_MS = (3 * 60 + 50) * 60 * 1000;
|
||||||
|
|
||||||
|
function scheduleEvery3Hrs50Mins() {
|
||||||
|
runBulkInventorySyncJob().catch(err => {
|
||||||
|
console.error('[Scheduler] Unhandled error in job run:', err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
runBulkInventorySyncJob().catch(err => {
|
||||||
|
console.error('[Scheduler] Unhandled error in job run:', err.message);
|
||||||
});
|
});
|
||||||
// logStep('Bulk Caller Job', `JOB COMPLETED`);
|
}, INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⏱ Schedule: every 3 hours and 40 minutes
|
scheduleEvery3Hrs50Mins();
|
||||||
function scheduleEvery3Hrs40Mins() {
|
|
||||||
runBulkInventorySyncJob(); // Run immediately on start
|
|
||||||
|
|
||||||
var intervalMs = (3 * 60 + 50) * 60 * 1000; // 13200000 ms = 3hr 50min
|
|
||||||
//intervalMs = (1 * 60 - 10) * 60 * 1000; // 13200000 ms = 3hr 40min
|
|
||||||
setInterval(runBulkInventorySyncJob, intervalMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduleEvery3Hrs40Mins();
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user