diff --git a/.gitignore b/.gitignore index c21c8b0..c45b1f8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ graphs_backfill_state.json graphs_backfill_stop.json product_structures_sync_state.json product_structures_sync_stop.json +production_orders_sync_state.json +production_orders_sync_stop.json diff --git a/docker-compose.yml b/docker-compose.yml index 2459bf6..330c8fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,12 @@ services: - PRODUCT_STRUCTURES_GRAPHS_API_KEY=${PRODUCT_STRUCTURES_GRAPHS_API_KEY} - PRODUCT_STRUCTURES_REQUEST_DELAY_MS=${PRODUCT_STRUCTURES_REQUEST_DELAY_MS:-2500} - PRODUCT_STRUCTURES_FETCH_COMPONENT_UNITS=${PRODUCT_STRUCTURES_FETCH_COMPONENT_UNITS:-true} + - PRODUCTION_ORDERS_TINY_ERP_COOKIE=${PRODUCTION_ORDERS_TINY_ERP_COOKIE} + - PRODUCTION_ORDERS_GRAPHS_API_URL=${PRODUCTION_ORDERS_GRAPHS_API_URL} + - PRODUCTION_ORDERS_GRAPHS_API_KEY=${PRODUCTION_ORDERS_GRAPHS_API_KEY} + - PRODUCTION_ORDERS_REQUEST_DELAY_MS=${PRODUCTION_ORDERS_REQUEST_DELAY_MS:-2500} + - PRODUCTION_ORDERS_DETAIL_BODY_TEMPLATE=${PRODUCTION_ORDERS_DETAIL_BODY_TEMPLATE:-id={{id}}} + - PRODUCTION_ORDERS_PRODUCT_DATA_BODY_TEMPLATE=${PRODUCTION_ORDERS_PRODUCT_DATA_BODY_TEMPLATE:-idProduto={{productId}}&quantidade={{quantity}}&gerarOP=N} - TINY_FETCH_SELLER_DETAILS=${TINY_FETCH_SELLER_DETAILS:-false} - TINY_ORDER_DETAILS_CACHE_TTL_MS=${TINY_ORDER_DETAILS_CACHE_TTL_MS:-120000} - TINY_LIVE_REQUEST_DELAY_MS=${TINY_LIVE_REQUEST_DELAY_MS:-6000} diff --git a/package.json b/package.json index d22a63b..3d9955f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start": "node dist/index.js", "backfill:graphs": "tsx src/scripts/backfill-graphs.ts", "sync:product-structures": "tsx src/scripts/sync-product-structures.ts", + "sync:production-orders": "tsx src/scripts/sync-production-orders.ts", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/src/index.ts b/src/index.ts index 43a3dd7..7662861 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 = { + 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; diff --git a/src/scripts/sync-production-orders.ts b/src/scripts/sync-production-orders.ts new file mode 100644 index 0000000..51b2445 --- /dev/null +++ b/src/scripts/sync-production-orders.ts @@ -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(); + +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) { + 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(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/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(/]*>[\s\S]*?<\/tr>/gi) || []; + + return rows.flatMap(row => { + const cells = row.match(/]*>[\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 = { + 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); +});