Add Tiny Olist production order sync
All checks were successful
Build and Deploy / build-and-push (push) Successful in 49s

This commit is contained in:
Cauê Faleiros
2026-07-23 14:36:45 -03:00
parent e7ea6b4276
commit 4547642411
5 changed files with 702 additions and 0 deletions

View File

@@ -296,6 +296,125 @@ app.get('/api/stop-product-structures-sync', (req: Request, res: Response) => {
});
});
// Hidden endpoint to sync Tiny/Olist production order details into Graphs
app.get('/api/trigger-production-orders-sync', (req: Request, res: Response) => {
const expectedToken = process.env.TINY_WEBHOOK_SECRET;
if (expectedToken && req.query.token !== expectedToken) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const scriptPath = path.join(__dirname, 'scripts', 'sync-production-orders.js');
const env = { ...process.env };
const stopFile = env.PRODUCTION_ORDERS_STOP_FILE || path.join(process.cwd(), 'production_orders_sync_stop.json');
const optionalEnvMap: Record<string, string> = {
orderIds: 'PRODUCTION_ORDER_IDS',
detailUrl: 'PRODUCTION_ORDERS_DETAIL_URL',
productDataUrl: 'PRODUCTION_ORDERS_PRODUCT_DATA_URL',
listUrl: 'PRODUCTION_ORDERS_LIST_URL',
listBody: 'PRODUCTION_ORDERS_LIST_BODY',
detailBodyTemplate: 'PRODUCTION_ORDERS_DETAIL_BODY_TEMPLATE',
productDataBodyTemplate: 'PRODUCTION_ORDERS_PRODUCT_DATA_BODY_TEMPLATE',
graphsUrl: 'PRODUCTION_ORDERS_GRAPHS_API_URL',
graphsApiKey: 'PRODUCTION_ORDERS_GRAPHS_API_KEY',
dryRun: 'PRODUCTION_ORDERS_DRY_RUN',
maxOrders: 'PRODUCTION_ORDERS_MAX_ORDERS',
requestDelayMs: 'PRODUCTION_ORDERS_REQUEST_DELAY_MS',
graphsRetryDelayMs: 'PRODUCTION_ORDERS_GRAPHS_RETRY_DELAY_MS',
graphsMaxRetries: 'PRODUCTION_ORDERS_GRAPHS_MAX_RETRIES'
};
for (const [queryKey, envKey] of Object.entries(optionalEnvMap)) {
const value = req.query[queryKey];
if (typeof value === 'string' && value.trim() !== '') {
env[envKey] = value.trim();
}
}
if (fs.existsSync(stopFile)) {
fs.rmSync(stopFile, { force: true });
}
const child = spawn('node', [scriptPath], {
detached: true,
stdio: 'inherit',
env
});
child.on('error', (error) => {
console.error(`[server]: Production orders sync process failed to start: ${error.message}`);
});
child.on('exit', (code, signal) => {
console.log(`[server]: Production orders sync process exited. code=${code ?? 'null'} signal=${signal ?? 'null'}`);
});
child.unref();
console.log('[server]: Production orders sync script manually triggered via HTTP endpoint.');
res.status(200).json({
status: 'STARTED',
message: 'Production orders sync is running in the background. Check Portainer logs for progress.',
options: {
orderIds: env.PRODUCTION_ORDER_IDS || null,
detailUrl: env.PRODUCTION_ORDERS_DETAIL_URL || 'https://erp.olist.com/services/ordem.producao.server/1/obterOrdemProducao',
productDataUrl: env.PRODUCTION_ORDERS_PRODUCT_DATA_URL || 'https://erp.olist.com/services/ordem.producao.server/1/buscarDadosProduto',
graphsUrl: env.PRODUCTION_ORDERS_GRAPHS_API_URL || env.GRAPHS_PRODUCTION_ORDERS_API_URL || deriveProductionOrdersUrl(env),
dryRun: env.PRODUCTION_ORDERS_DRY_RUN || 'false',
maxOrders: env.PRODUCTION_ORDERS_MAX_ORDERS || null,
requestDelayMs: env.PRODUCTION_ORDERS_REQUEST_DELAY_MS || '2500',
tinyErpAuthConfigured: Boolean(env.PRODUCTION_ORDERS_TINY_ERP_COOKIE || env.PRODUCTION_ORDERS_TINY_ERP_AUTHORIZATION),
graphsApiKeyConfigured: Boolean(env.PRODUCTION_ORDERS_GRAPHS_API_KEY || env.GRAPHS_PRODUCTION_ORDERS_API_KEY || env.GRAPHS_API_KEY || env.NEXSTAR_GRAPHS_API_KEY || env.API_KEY)
}
});
});
app.get('/api/production-orders-sync-status', (req: Request, res: Response) => {
const expectedToken = process.env.TINY_WEBHOOK_SECRET;
if (expectedToken && req.query.token !== expectedToken) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const stateFile = process.env.PRODUCTION_ORDERS_STATE_FILE || path.join(process.cwd(), 'production_orders_sync_state.json');
const stopFile = process.env.PRODUCTION_ORDERS_STOP_FILE || path.join(process.cwd(), 'production_orders_sync_stop.json');
let state = null;
if (fs.existsSync(stateFile)) {
try {
state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
} catch (error: any) {
res.status(500).json({ error: `Could not read production orders sync state: ${error.message}` });
return;
}
}
res.status(200).json({
status: state?.status || 'unknown',
stopRequested: fs.existsSync(stopFile),
state
});
});
app.get('/api/stop-production-orders-sync', (req: Request, res: Response) => {
const expectedToken = process.env.TINY_WEBHOOK_SECRET;
if (expectedToken && req.query.token !== expectedToken) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const stopFile = process.env.PRODUCTION_ORDERS_STOP_FILE || path.join(process.cwd(), 'production_orders_sync_stop.json');
fs.writeFileSync(stopFile, JSON.stringify({
requestedAt: new Date().toISOString()
}, null, 2));
res.status(200).json({
status: 'STOP_REQUESTED',
message: 'Production orders sync will stop after the current wait/request finishes.'
});
});
// Hidden endpoint to download the stock CSV log directly from the browser
app.get('/api/stock-logs/download', (req: Request, res: Response) => {
const expectedToken = process.env.TINY_WEBHOOK_SECRET;

View File

@@ -0,0 +1,574 @@
import axios from 'axios';
import dotenv from 'dotenv';
import fs from 'fs';
import path from 'path';
dotenv.config();
type ProductionOrderPayload = {
order: {
tinyId: string;
number: string;
status: string;
productSku: string;
productDescription: string;
quantity: string;
unit: string;
issueDate: string | null;
expectedDate: string | null;
supplier: string;
lotCode: string;
notes: string;
};
components: Array<{
componentName: string;
componentSku: string;
quantityPerUnit: string;
totalQuantity: string;
unit: string;
}>;
steps: Array<{
stepNumber: number;
name: string;
startDate: string | null;
endDate: string | null;
status: string;
}>;
};
type XajaxCommand = {
cmd?: string;
elm?: string;
prop?: string;
val?: string;
src?: string;
fn?: string;
args?: any[];
};
type SyncState = {
status?: 'running' | 'stopped' | 'completed';
processedOrders?: number;
failedOrders?: number;
lastTinyId?: string;
lastFailedTinyId?: string;
lastMessage?: string;
updatedAt?: string;
};
function deriveProductionOrdersUrl() {
const raw = process.env.GRAPHS_API_URL || process.env.NEXSTAR_GRAPHS_API_URL || '';
if (!raw) return '';
try {
const parsed = new URL(raw);
return `${parsed.origin}/api/production-orders/tiny-sync`;
} catch {
return raw.replace(/\/api\/data.*$/, '/api/production-orders/tiny-sync');
}
}
const TINY_ERP_BASE_URL = (process.env.PRODUCTION_ORDERS_TINY_ERP_BASE_URL || 'https://erp.olist.com').replace(/\/+$/, '');
const TINY_ERP_COOKIE = process.env.PRODUCTION_ORDERS_TINY_ERP_COOKIE || '';
const TINY_ERP_AUTHORIZATION = process.env.PRODUCTION_ORDERS_TINY_ERP_AUTHORIZATION || '';
const DETAIL_URL = process.env.PRODUCTION_ORDERS_DETAIL_URL || `${TINY_ERP_BASE_URL}/services/ordem.producao.server/1/obterOrdemProducao`;
const PRODUCT_DATA_URL = process.env.PRODUCTION_ORDERS_PRODUCT_DATA_URL || `${TINY_ERP_BASE_URL}/services/ordem.producao.server/1/buscarDadosProduto`;
const LIST_URL = process.env.PRODUCTION_ORDERS_LIST_URL || `${TINY_ERP_BASE_URL}/services/ordem.producao.server/1/listarOrdensProducao`;
const DETAIL_BODY_TEMPLATE = process.env.PRODUCTION_ORDERS_DETAIL_BODY_TEMPLATE || 'id={{id}}';
const PRODUCT_DATA_BODY_TEMPLATE = process.env.PRODUCTION_ORDERS_PRODUCT_DATA_BODY_TEMPLATE || 'idProduto={{productId}}&quantidade={{quantity}}&gerarOP=N';
const LIST_BODY = process.env.PRODUCTION_ORDERS_LIST_BODY || '';
const GRAPHS_PRODUCTION_ORDERS_URL = (
process.env.PRODUCTION_ORDERS_GRAPHS_API_URL ||
process.env.GRAPHS_PRODUCTION_ORDERS_API_URL ||
(process.env.GRAPHS_API_BASE_URL ? `${process.env.GRAPHS_API_BASE_URL.replace(/\/+$/, '')}/api/production-orders/tiny-sync` : '') ||
deriveProductionOrdersUrl() ||
'http://localhost:3004/api/production-orders/tiny-sync'
);
const GRAPHS_PRODUCTION_ORDERS_API_KEY = (
process.env.PRODUCTION_ORDERS_GRAPHS_API_KEY ||
process.env.GRAPHS_PRODUCTION_ORDERS_API_KEY ||
process.env.PRODUCTION_ORDERS_API_KEY ||
process.env.GRAPHS_API_KEY ||
process.env.NEXSTAR_GRAPHS_API_KEY ||
process.env.API_KEY ||
'nexstar_secret_key_123'
);
const TINY_API_TOKEN = process.env.TINY_API_TOKEN || '';
const ORDER_IDS = String(process.env.PRODUCTION_ORDER_IDS || '')
.split(',')
.map(id => id.trim())
.filter(Boolean);
const DRY_RUN = process.env.PRODUCTION_ORDERS_DRY_RUN === 'true';
const STATE_FILE = process.env.PRODUCTION_ORDERS_STATE_FILE || path.join(process.cwd(), 'production_orders_sync_state.json');
const STOP_FILE = process.env.PRODUCTION_ORDERS_STOP_FILE || path.join(process.cwd(), 'production_orders_sync_stop.json');
const numberEnv = (name: string, fallback: number) => {
const raw = process.env[name];
if (raw === undefined || raw.trim() === '') return fallback;
const value = Number(raw);
return Number.isFinite(value) && value >= 0 ? value : fallback;
};
const REQUEST_DELAY_MS = numberEnv('PRODUCTION_ORDERS_REQUEST_DELAY_MS', 2500);
const GRAPHS_RETRY_DELAY_MS = numberEnv('PRODUCTION_ORDERS_GRAPHS_RETRY_DELAY_MS', 30000);
const GRAPHS_MAX_RETRIES = numberEnv('PRODUCTION_ORDERS_GRAPHS_MAX_RETRIES', 3);
const MAX_ORDERS = numberEnv('PRODUCTION_ORDERS_MAX_ORDERS', 0);
let nextRequestAt = 0;
const productCache = new Map<string, { sku: string; description: string; unit: string } | null>();
class SyncStoppedError extends Error {
constructor() {
super('Production orders sync stopped by request.');
}
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
function assertNotStopped() {
if (fs.existsSync(STOP_FILE)) {
throw new SyncStoppedError();
}
}
async function sleepWithStop(ms: number, reason: string) {
if (ms <= 0) return;
const startedAt = Date.now();
let remaining = ms;
console.log(`[wait] Waiting ${ms}ms before ${reason}.`);
while (remaining > 0) {
assertNotStopped();
await sleep(Math.min(remaining, 1000));
remaining = ms - (Date.now() - startedAt);
}
}
async function waitForRequestSlot(reason: string) {
const waitMs = Math.max(0, nextRequestAt - Date.now());
await sleepWithStop(waitMs, reason);
nextRequestAt = Date.now() + REQUEST_DELAY_MS;
}
function saveState(state: SyncState) {
fs.writeFileSync(STATE_FILE, JSON.stringify({
...state,
updatedAt: new Date().toISOString()
}, null, 2));
}
function describeRequestError(error: any) {
if (error?.response) {
const body = JSON.stringify(error.response.data ?? '');
const truncatedBody = body.length > 500 ? `${body.slice(0, 500)}...` : body;
return `HTTP ${error.response.status}: ${truncatedBody}`;
}
return error?.message || String(error);
}
function normalizeText(value: unknown) {
return String(value ?? '').trim();
}
function pickFirst(...values: unknown[]) {
for (const value of values) {
const normalized = normalizeText(value);
if (normalized) return normalized;
}
return '';
}
function pickFirstNullable(...values: unknown[]) {
const value = pickFirst(...values);
return value || null;
}
function fillTemplate(template: string, values: Record<string, string>) {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => encodeURIComponent(values[key] || ''));
}
function parseResponse(data: any) {
if (typeof data === 'object' && data !== null) return data;
const raw = String(data ?? '').trim();
if (!raw) return {};
try {
return JSON.parse(raw);
} catch {
const jsonStart = raw.indexOf('{');
const jsonEnd = raw.lastIndexOf('}');
if (jsonStart >= 0 && jsonEnd > jsonStart) {
return JSON.parse(raw.slice(jsonStart, jsonEnd + 1));
}
}
throw new Error('Could not parse Tiny/Olist internal response as JSON.');
}
function getCommands(data: any): XajaxCommand[] {
const parsed = parseResponse(data);
return Array.isArray(parsed?.response) ? parsed.response : [];
}
function getAssignedValue(commands: XajaxCommand[], element: string) {
const command = commands.find(entry => entry.cmd === 'as' && entry.elm === element);
return normalizeText(command?.val);
}
function getScript(commands: XajaxCommand[], pattern: RegExp) {
return commands.find(entry => entry.cmd === 'sc' && pattern.test(String(entry.src || '')))?.src || '';
}
function decodeHtml(value: string) {
return value
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function stripHtml(value: string) {
return decodeHtml(value.replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim();
}
function parseQuantityAndUnit(value: string) {
const normalized = stripHtml(value);
const match = normalized.match(/^(.+?)\s+([A-Za-z]{1,5})$/);
if (!match) return { quantity: normalized, unit: '' };
return { quantity: match[1].trim(), unit: match[2].trim() };
}
function parseCompositionHtml(html: string): ProductionOrderPayload['components'] {
const rows = html.match(/<tr\b[^>]*>[\s\S]*?<\/tr>/gi) || [];
return rows.flatMap(row => {
const cells = row.match(/<td\b[^>]*>[\s\S]*?<\/td>/gi) || [];
if (cells.length < 4) return [];
const componentName = stripHtml(cells[0] || '');
const componentSku = stripHtml(cells[1] || '');
const perUnit = parseQuantityAndUnit(cells[2] || '');
const total = parseQuantityAndUnit(cells[3] || '');
return [{
componentName,
componentSku,
quantityPerUnit: perUnit.quantity,
totalQuantity: total.quantity,
unit: perUnit.unit || total.unit
}];
}).filter(component => component.componentName || component.componentSku);
}
function parseSteps(commands: XajaxCommand[]): ProductionOrderPayload['steps'] {
const script = getScript(commands, /addDetails\(/);
const match = script.match(/addDetails\(([\s\S]*)\)/);
if (!match) return [];
try {
const steps = JSON.parse(match[1]);
return (Array.isArray(steps) ? steps : []).map((step, index) => ({
stepNumber: index + 1,
name: pickFirst(step?.tarefa, step?.name, 'Etapa'),
startDate: pickFirstNullable(step?.dataInicio),
endDate: pickFirstNullable(step?.dataTermino),
status: /Pendente/i.test(String(step?.situacao || '')) ? 'pendente' : pickFirst(step?.situacao, 'pendente')
}));
} catch (error: any) {
console.warn(`[parse] Could not parse OP steps: ${error.message}`);
return [];
}
}
function parseStatus(commands: XajaxCommand[]) {
const html = getAssignedValue(commands, 'view-info-ordem-producao');
if (!html) return '';
return stripHtml(html).replace(/^[-\s]+/, '');
}
function parseNotesMeta(notes: string) {
return {
supplier: notes.match(/FONE?CEDOR\s*:\s*([^\n]+)/i)?.[1]?.trim() || '',
lotCode: notes.match(/LOTE\s*:\s*([^\n]+)/i)?.[1]?.trim() || ''
};
}
function parseBuscarDadosProdutoCall(commands: XajaxCommand[]) {
const script = getScript(commands, /buscarDadosProduto\(/);
const match = script.match(/buscarDadosProduto\(([^,]+),\s*'([^']*)',\s*'([^']*)'\)/);
if (!match) return null;
return {
productId: normalizeText(match[1]),
quantity: normalizeText(match[2]),
flag: normalizeText(match[3])
};
}
async function tinyErpPost(url: string, body: string, reason: string) {
await waitForRequestSlot(reason);
const headers: Record<string, string> = {
Accept: 'application/json, text/javascript, */*; q=0.01',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
Origin: TINY_ERP_BASE_URL,
Referer: `${TINY_ERP_BASE_URL}/ordens_producao`,
'X-Custom-Request-For': 'XAJAX',
'X-Requested-With': 'XMLHttpRequest',
'X-User-Agent': 'Mozilla/5.0'
};
if (TINY_ERP_COOKIE) headers.Cookie = TINY_ERP_COOKIE;
if (TINY_ERP_AUTHORIZATION) headers.Authorization = TINY_ERP_AUTHORIZATION;
return axios.post(url, body, { headers });
}
async function fetchTinyProduct(productId: string) {
if (!TINY_API_TOKEN || !productId) return null;
if (productCache.has(productId)) return productCache.get(productId) || null;
await waitForRequestSlot(`Tiny produto.obter.php ${productId}`);
const params = new URLSearchParams();
params.append('token', TINY_API_TOKEN);
params.append('formato', 'JSON');
params.append('id', productId);
try {
const response = await axios.post('https://api.tiny.com.br/api2/produto.obter.php', params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
const produto = response.data?.retorno?.produto;
const normalized = produto ? {
sku: normalizeText(produto.codigo),
description: normalizeText(produto.nome),
unit: normalizeText(produto.unidade)
} : null;
productCache.set(productId, normalized);
return normalized;
} catch (error: any) {
console.warn(`[Tiny] Could not fetch product ${productId}: ${describeRequestError(error)}`);
productCache.set(productId, null);
return null;
}
}
async function fetchOpDetail(orderId: string) {
const body = fillTemplate(DETAIL_BODY_TEMPLATE, { id: orderId });
const response = await tinyErpPost(DETAIL_URL, body, `Tiny/Olist OP detail ${orderId}`);
return getCommands(response.data);
}
async function fetchComposition(productId: string, quantity: string, flag: string) {
const body = fillTemplate(PRODUCT_DATA_BODY_TEMPLATE, { productId, quantity, flag });
const response = await tinyErpPost(PRODUCT_DATA_URL, body, `Tiny/Olist product composition ${productId}`);
const commands = getCommands(response.data);
const compositionHtml = getAssignedValue(commands, 'div-composicao');
return parseCompositionHtml(compositionHtml);
}
function normalizeDetail(
orderId: string,
commands: XajaxCommand[],
components: ProductionOrderPayload['components'],
finishedProduct: { sku: string; description: string; unit: string } | null
): ProductionOrderPayload | null {
const notes = getAssignedValue(commands, 'observacoes');
const notesMeta = parseNotesMeta(notes);
const productData = parseBuscarDadosProdutoCall(commands);
const productId = getAssignedValue(commands, 'idProduto');
const productSku = getAssignedValue(commands, 'codigo') || getAssignedValue(commands, 'sku') || finishedProduct?.sku || productId;
const payload: ProductionOrderPayload = {
order: {
tinyId: getAssignedValue(commands, 'id') || orderId,
number: getAssignedValue(commands, 'numero') || orderId,
status: parseStatus(commands) || getAssignedValue(commands, 'situacao'),
productSku,
productDescription: getAssignedValue(commands, 'produto') || finishedProduct?.description || '',
quantity: getAssignedValue(commands, 'quantidade') || productData?.quantity || '',
unit: stripHtml(getAssignedValue(commands, 'un-produto')) || finishedProduct?.unit || 'UN',
issueDate: pickFirstNullable(getAssignedValue(commands, 'dataInicio')),
expectedDate: pickFirstNullable(getAssignedValue(commands, 'dataPrevista')),
supplier: notesMeta.supplier,
lotCode: notesMeta.lotCode,
notes
},
components,
steps: parseSteps(commands)
};
if (!payload.order.tinyId || !payload.order.productDescription || !payload.order.quantity || !payload.components.length) {
console.warn(`[normalize] OP ${orderId} missing required data. tinyId=${payload.order.tinyId || 'none'} product=${payload.order.productDescription || 'none'} quantity=${payload.order.quantity || 'none'} components=${payload.components.length}`);
return null;
}
return payload;
}
async function loadOrderIdsFromList() {
if (!LIST_BODY) return [];
const response = await tinyErpPost(LIST_URL, LIST_BODY, 'Tiny/Olist OP list');
const commands = getCommands(response.data);
const serialized = JSON.stringify(commands);
return Array.from(new Set(Array.from(serialized.matchAll(/(?:idOrdemProducao|id)["':\\]+(\d{6,})/g)).map(match => match[1])));
}
async function loadOrderIds() {
if (ORDER_IDS.length) return ORDER_IDS;
const listedIds = await loadOrderIdsFromList();
if (listedIds.length) return listedIds;
throw new Error('No OP source configured. Set PRODUCTION_ORDER_IDS first. Later we can use PRODUCTION_ORDERS_LIST_BODY once the listarOrdensProducao Form Data is captured.');
}
async function sendProductionOrder(payload: ProductionOrderPayload) {
if (DRY_RUN) {
console.log(`[dry-run] Would send OP ${payload.order.tinyId || payload.order.number}`);
console.log(JSON.stringify(payload, null, 2));
return true;
}
let attempt = 0;
while (true) {
try {
await waitForRequestSlot(`Graphs production order ${payload.order.tinyId || payload.order.number}`);
await axios.post(GRAPHS_PRODUCTION_ORDERS_URL, payload, {
headers: {
'Content-Type': 'application/json',
'x-api-key': GRAPHS_PRODUCTION_ORDERS_API_KEY
}
});
console.log(`[Graphs OP] Sent OP ${payload.order.tinyId || payload.order.number} with ${payload.components.length} component(s).`);
return true;
} catch (error: any) {
attempt += 1;
const status = error?.response?.status;
const shouldRetry = !status || status >= 500;
console.error(`[Graphs OP] Failed to send OP ${payload.order.tinyId || payload.order.number} on attempt ${attempt}: ${describeRequestError(error)}`);
if (!shouldRetry || (GRAPHS_MAX_RETRIES && attempt >= GRAPHS_MAX_RETRIES)) {
console.error(`[Graphs OP] Giving up on OP ${payload.order.tinyId || payload.order.number}; sync will continue.`);
return false;
}
await sleepWithStop(GRAPHS_RETRY_DELAY_MS, `retrying Graphs OP ${payload.order.tinyId || payload.order.number}`);
}
}
}
async function runSync() {
if (!TINY_ERP_COOKIE && !TINY_ERP_AUTHORIZATION) {
throw new Error('Missing Tiny/Olist browser auth. Set PRODUCTION_ORDERS_TINY_ERP_COOKIE from the browser session cookie.');
}
console.log(`[config] Detail URL: ${DETAIL_URL}`);
console.log(`[config] Product data URL: ${PRODUCT_DATA_URL}`);
console.log(`[config] Graphs OP URL: ${GRAPHS_PRODUCTION_ORDERS_URL}`);
console.log(`[config] Dry run: ${DRY_RUN ? 'yes' : 'no'}`);
console.log(`[config] Request pace: 1 request every ${REQUEST_DELAY_MS}ms`);
console.log(`[config] State file: ${STATE_FILE}`);
console.log(`[config] Stop file: ${STOP_FILE}`);
saveState({
status: 'running',
processedOrders: 0,
failedOrders: 0,
lastMessage: 'Production orders sync started'
});
const orderIds = await loadOrderIds();
console.log(`[source] Found ${orderIds.length} OP id(s).`);
let processedOrders = 0;
let failedOrders = 0;
for (const orderId of orderIds) {
assertNotStopped();
if (MAX_ORDERS && processedOrders + failedOrders >= MAX_ORDERS) {
console.log(`[limit] Stopped after ${MAX_ORDERS} OP(s).`);
break;
}
try {
const detailCommands = await fetchOpDetail(orderId);
const productData = parseBuscarDadosProdutoCall(detailCommands);
if (!productData?.productId) {
throw new Error(`Could not find buscarDadosProduto call for OP ${orderId}.`);
}
const components = await fetchComposition(productData.productId, productData.quantity, productData.flag);
const finishedProduct = await fetchTinyProduct(productData.productId);
const payload = normalizeDetail(orderId, detailCommands, components, finishedProduct);
if (!payload) {
failedOrders += 1;
saveState({
status: 'running',
processedOrders,
failedOrders,
lastFailedTinyId: orderId,
lastMessage: `Could not normalize OP ${orderId}`
});
continue;
}
const sent = await sendProductionOrder(payload);
if (sent) {
processedOrders += 1;
} else {
failedOrders += 1;
}
saveState({
status: 'running',
processedOrders,
failedOrders,
lastTinyId: sent ? payload.order.tinyId : undefined,
lastFailedTinyId: sent ? undefined : payload.order.tinyId,
lastMessage: sent ? `Synced OP ${payload.order.tinyId}` : `Failed OP ${payload.order.tinyId}; continuing`
});
} catch (error: any) {
failedOrders += 1;
console.error(`[Tiny/Olist OP] Failed OP ${orderId}: ${describeRequestError(error)}`);
saveState({
status: 'running',
processedOrders,
failedOrders,
lastFailedTinyId: orderId,
lastMessage: `Failed OP ${orderId}; continuing`
});
}
}
saveState({
status: 'completed',
processedOrders,
failedOrders,
lastMessage: failedOrders ? `Production orders sync completed with ${failedOrders} failed OP(s)` : 'Production orders sync completed'
});
console.log(`Done. Synced ${processedOrders} OP(s). Failed OPs: ${failedOrders}.`);
}
runSync().catch((error: any) => {
if (error instanceof SyncStoppedError) {
console.log('Production orders sync stopped by request.');
saveState({
status: 'stopped',
lastMessage: 'Production orders sync stopped by request'
});
process.exit(0);
}
console.error(`Production orders sync failed: ${describeRequestError(error)}`);
saveState({
status: 'stopped',
lastMessage: `Production orders sync failed: ${describeRequestError(error)}`
});
process.exit(1);
});