From 3234ab6160d1a282684696f9859eca70d42f66d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 22 Jun 2026 15:49:22 -0300 Subject: [PATCH] feat: sync Tiny orders directly to Graphs --- .gitignore | 3 +- package.json | 1 + src/controllers/webhook.controller.ts | 6 + src/index.ts | 50 +++ src/scripts/backfill-graphs.ts | 430 ++++++++++++++++++++++++++ src/services/graphs.service.ts | 122 ++++++++ 6 files changed, 611 insertions(+), 1 deletion(-) create mode 100644 src/scripts/backfill-graphs.ts create mode 100644 src/services/graphs.service.ts diff --git a/.gitignore b/.gitignore index a0d218e..a2a8a40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules dist -.env \ No newline at end of file +.env +graphs_backfill_state.json diff --git a/package.json b/package.json index fb33950..df20fac 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", + "backfill:graphs": "tsx src/scripts/backfill-graphs.ts", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index 924a8ed..df7d3c7 100644 --- a/src/controllers/webhook.controller.ts +++ b/src/controllers/webhook.controller.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import { sendToN8n } from '../services/n8n.service'; +import { sendTinyOrderToGraphs } from '../services/graphs.service'; import axios from 'axios'; import fs from 'fs'; import path from 'path'; @@ -131,6 +132,11 @@ export const handleTinyOrderUpdate = async (req: Request, res: Response): Promis dispatchPromises.push(sendToN8n(finalPayload, graphsUrl)); } + if (fullOrderDetails && (process.env.GRAPHS_API_URL || process.env.NEXSTAR_GRAPHS_API_URL)) { + console.log('-> Preparing direct dispatch to Graphs API...'); + dispatchPromises.push(sendTinyOrderToGraphs(fullOrderDetails)); + } + // Fire them all in parallel so one does not block the other await Promise.allSettled(dispatchPromises); console.log('All n8n dispatch attempts completed.'); diff --git a/src/index.ts b/src/index.ts index 0291848..e7643fd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -40,6 +40,56 @@ app.get('/api/trigger-backfill', (req: Request, res: Response) => { res.status(200).json({ status: 'STARTED', message: 'Backfill script is now running quietly in the background.' }); }); +// Hidden endpoint to trigger the Graphs backfill script without terminal access +app.get('/api/trigger-graphs-backfill', (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', 'backfill-graphs.js'); + const env = { ...process.env }; + + const optionalEnvMap: Record = { + start: 'GRAPHS_BACKFILL_START_DATE', + end: 'GRAPHS_BACKFILL_END_DATE', + dryRun: 'GRAPHS_BACKFILL_DRY_RUN', + maxOrders: 'GRAPHS_BACKFILL_MAX_ORDERS', + maxDays: 'GRAPHS_BACKFILL_MAX_DAYS', + contactFallback: 'GRAPHS_BACKFILL_CONTACT_FALLBACK' + }; + + for (const [queryKey, envKey] of Object.entries(optionalEnvMap)) { + const value = req.query[queryKey]; + if (typeof value === 'string' && value.trim() !== '') { + env[envKey] = value.trim(); + } + } + + const child = spawn('node', [scriptPath], { + detached: true, + stdio: 'inherit', + env + }); + + child.unref(); + + console.log('[server]: Graphs backfill script manually triggered via HTTP endpoint.'); + res.status(200).json({ + status: 'STARTED', + message: 'Graphs backfill script is running in the background. Check Portainer logs for progress.', + options: { + start: env.GRAPHS_BACKFILL_START_DATE || null, + end: env.GRAPHS_BACKFILL_END_DATE || null, + dryRun: env.GRAPHS_BACKFILL_DRY_RUN || 'false', + maxOrders: env.GRAPHS_BACKFILL_MAX_ORDERS || null, + maxDays: env.GRAPHS_BACKFILL_MAX_DAYS || null, + contactFallback: env.GRAPHS_BACKFILL_CONTACT_FALLBACK || 'true' + } + }); +}); + // 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/backfill-graphs.ts b/src/scripts/backfill-graphs.ts new file mode 100644 index 0000000..1719288 --- /dev/null +++ b/src/scripts/backfill-graphs.ts @@ -0,0 +1,430 @@ +import axios from 'axios'; +import dotenv from 'dotenv'; +import fs from 'fs'; +import path from 'path'; + +dotenv.config(); + +type TinyOrderSummary = { + id?: string | number; + numero?: string | number; + data_pedido?: string; + data?: string; + valor?: string | number; +}; + +type TinyOrderDetails = { + pedido: any; + status_processamento?: string; +}; + +type GraphsOrderRow = { + Nome_Cliente: string; + Data_Pedido: string; + Valor_Pedido: number; + ID_Produto: string; + Descricao_Produto: string; + Quantidade: number; + Valor_Unitario: number; + ID_Pedido: string; + Fone_Cliente: string; + cliente_nome_fantasia: string; + id_vendedor: string; + nome_vendedor: string; + marketplace: string; + canal_venda: string; + numero_ecommerce: string; +}; + +type ContactInfo = { + fantasia: string; + phone: string; +}; + +type BackfillState = { + nextDate: string; + lastCompletedDate?: string; + updatedAt?: string; +}; + +const TINY_API_TOKEN = process.env.TINY_API_TOKEN; +const GRAPHS_API_URL = process.env.GRAPHS_API_URL || process.env.NEXSTAR_GRAPHS_API_URL; +const GRAPHS_API_KEY = process.env.GRAPHS_API_KEY || process.env.NEXSTAR_GRAPHS_API_KEY; +const DRY_RUN = process.env.GRAPHS_BACKFILL_DRY_RUN === 'true'; +const CONTACT_FALLBACK = process.env.GRAPHS_BACKFILL_CONTACT_FALLBACK !== 'false'; +const NORMALIZE_PHONE = process.env.GRAPHS_BACKFILL_NORMALIZE_PHONE !== 'false'; +const START_DATE = process.env.GRAPHS_BACKFILL_START_DATE || '13/02/2025'; +const END_DATE = process.env.GRAPHS_BACKFILL_END_DATE || getTodayDate(); +const MAX_DAYS = Number(process.env.GRAPHS_BACKFILL_MAX_DAYS || 0); +const MAX_ORDERS = Number(process.env.GRAPHS_BACKFILL_MAX_ORDERS || 0); +const TINY_SEARCH_DELAY_MS = Number(process.env.TINY_SEARCH_DELAY_MS || 1000); +const TINY_DETAIL_DELAY_MS = Number(process.env.TINY_DETAIL_DELAY_MS || 1500); +const TINY_CONTACT_DELAY_MS = Number(process.env.TINY_CONTACT_DELAY_MS || TINY_DETAIL_DELAY_MS); +const STATE_FILE = process.env.GRAPHS_BACKFILL_STATE_FILE || path.join(process.cwd(), 'graphs_backfill_state.json'); + +const contactCache = new Map(); + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +function getTodayDate() { + const date = new Date(); + return formatDate(date); +} + +function parseDate(dateStr: string) { + const [day, month, year] = dateStr.split('/').map(Number); + const date = new Date(year, month - 1, day); + + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + throw new Error(`Invalid date. Use DD/MM/YYYY: ${dateStr}`); + } + + return date; +} + +function formatDate(date: Date) { + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + return `${day}/${month}/${year}`; +} + +function getNextDate(dateStr: string) { + const date = parseDate(dateStr); + date.setDate(date.getDate() + 1); + return formatDate(date); +} + +function isAfter(dateStr: string, comparisonDateStr: string) { + return parseDate(dateStr).getTime() > parseDate(comparisonDateStr).getTime(); +} + +function loadState(): BackfillState { + if (process.env.GRAPHS_BACKFILL_START_DATE) { + return { nextDate: START_DATE }; + } + + if (!fs.existsSync(STATE_FILE)) { + return { nextDate: START_DATE }; + } + + try { + const parsed = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); + if (parsed?.nextDate) return parsed; + if (parsed?.lastProcessedDate) return { nextDate: parsed.lastProcessedDate }; + } catch (error: any) { + console.warn(`[state] Could not read ${STATE_FILE}: ${error.message}`); + } + + return { nextDate: START_DATE }; +} + +function saveState(state: BackfillState) { + fs.writeFileSync(STATE_FILE, JSON.stringify({ + ...state, + updatedAt: new Date().toISOString() + }, null, 2)); +} + +function normalizeNumber(value: unknown) { + if (typeof value === 'number') return Number.isFinite(value) ? value : 0; + if (value === undefined || value === null) return 0; + + const raw = String(value).trim(); + const normalized = raw.includes(',') + ? raw.replace(/\./g, '').replace(',', '.') + : raw; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : 0; +} + +function normalizeCpfCnpj(value: unknown) { + return String(value || '').replace(/\D+/g, ''); +} + +function normalizePhone(value: unknown) { + const raw = String(value || '').trim(); + if (!raw) return ''; + if (!NORMALIZE_PHONE) return raw; + + const digits = raw.replace(/\D+/g, ''); + if (!digits) return ''; + if (digits.startsWith('55') && (digits.length === 12 || digits.length === 13)) return digits; + if (digits.length === 10 || digits.length === 11) return `55${digits}`; + return digits; +} + +function pickFirst(...values: unknown[]) { + for (const value of values) { + const normalized = String(value ?? '').trim(); + if (normalized) return normalized; + } + + return ''; +} + +async function tinyPost(servicePhp: string, params: URLSearchParams) { + return axios.post(`https://api.tiny.com.br/api2/${servicePhp}`, params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }); +} + +async function fetchOrdersForDate(dateStr: string, page = 1): Promise { + console.log(`[Tiny] Searching orders for ${dateStr}, page ${page}`); + + const params = new URLSearchParams(); + params.append('token', TINY_API_TOKEN!); + params.append('formato', 'JSON'); + params.append('dataInicial', dateStr); + params.append('dataFinal', dateStr); + params.append('pagina', String(page)); + + const response = await tinyPost('pedidos.pesquisa.php', params); + await sleep(TINY_SEARCH_DELAY_MS); + + const retorno = response.data?.retorno; + if (retorno?.status === 'OK') { + const orders = (retorno.pedidos || []).map((entry: any) => entry.pedido).filter(Boolean); + const pageCount = Number(retorno.numero_paginas || 1); + + if (page < pageCount) { + return orders.concat(await fetchOrdersForDate(dateStr, page + 1)); + } + + return orders; + } + + if (retorno?.codigo_erro === '20') { + return []; + } + + console.error(`[Tiny] Failed to search orders for ${dateStr}:`, JSON.stringify(retorno?.erros || retorno || 'Unknown error')); + return []; +} + +async function fetchOrderDetails(orderId: string): Promise { + const params = new URLSearchParams(); + params.append('token', TINY_API_TOKEN!); + params.append('id', orderId); + params.append('formato', 'JSON'); + + try { + const response = await tinyPost('pedido.obter.php', params); + await sleep(TINY_DETAIL_DELAY_MS); + + if (response.data?.retorno?.status === 'OK') { + return { + pedido: response.data.retorno.pedido, + status_processamento: response.data.retorno.status_processamento + }; + } + + console.error(`[Tiny] Failed to fetch order ${orderId}:`, JSON.stringify(response.data?.retorno?.erros || 'Unknown error')); + return null; + } catch (error: any) { + console.error(`[Tiny] Request failed for order ${orderId}: ${error.message}`); + await sleep(5000); + return null; + } +} + +async function fetchContactInfo(cliente: any): Promise { + if (!CONTACT_FALLBACK) return null; + + const cpfCnpj = normalizeCpfCnpj(cliente?.cpf_cnpj); + const name = pickFirst(cliente?.nome, cliente?.razao_social); + const cacheKey = cpfCnpj || name.toLowerCase(); + if (!cacheKey) return null; + if (contactCache.has(cacheKey)) return contactCache.get(cacheKey) || null; + + try { + const searchParams = new URLSearchParams(); + searchParams.append('token', TINY_API_TOKEN!); + searchParams.append('formato', 'JSON'); + searchParams.append('pagina', '1'); + searchParams.append('pesquisa', name || cpfCnpj); + if (cpfCnpj) searchParams.append('cpf_cnpj', cpfCnpj); + + const searchResponse = await tinyPost('contatos.pesquisa.php', searchParams); + await sleep(TINY_CONTACT_DELAY_MS); + + const contatoResumo = searchResponse.data?.retorno?.contatos?.[0]?.contato; + const contatoId = contatoResumo?.id; + if (!contatoId) { + contactCache.set(cacheKey, null); + return null; + } + + const detailParams = new URLSearchParams(); + detailParams.append('token', TINY_API_TOKEN!); + detailParams.append('formato', 'JSON'); + detailParams.append('id', String(contatoId)); + + const detailResponse = await tinyPost('contato.obter.php', detailParams); + await sleep(TINY_CONTACT_DELAY_MS); + + if (detailResponse.data?.retorno?.status !== 'OK') { + contactCache.set(cacheKey, null); + return null; + } + + const contato = detailResponse.data.retorno.contato || {}; + const info = { + fantasia: pickFirst(contato.fantasia, contato.nome_fantasia), + phone: normalizePhone(pickFirst(contato.celular, contato.fone, contato.telefone)) + }; + + contactCache.set(cacheKey, info); + return info; + } catch (error: any) { + console.error(`[Tiny] Contact fallback failed for ${name || cpfCnpj}: ${error.message}`); + contactCache.set(cacheKey, null); + return null; + } +} + +async function buildGraphsRows(details: TinyOrderDetails, summary: TinyOrderSummary): Promise { + const pedido = details.pedido || {}; + const cliente = pedido.cliente || {}; + const itens = Array.isArray(pedido.itens) ? pedido.itens : []; + const orderId = pickFirst(pedido.id, summary.id); + const orderTotal = normalizeNumber(pickFirst(pedido.total_pedido, pedido.valor_total, summary.valor)); + const customerName = pickFirst(cliente.nome, pedido.nome, (summary as any).nome, 'Unknown'); + const orderCustomerPhone = normalizePhone(pickFirst( + cliente.celular, + cliente.telefone, + cliente.fone + )); + const orderCustomerFantasyName = pickFirst( + cliente.nome_fantasia, + cliente.fantasia + ); + const contactInfo = (!orderCustomerPhone || !orderCustomerFantasyName) + ? await fetchContactInfo(cliente) + : null; + const customerPhone = normalizePhone(pickFirst(orderCustomerPhone, contactInfo?.phone)); + const customerFantasyName = pickFirst(orderCustomerFantasyName, contactInfo?.fantasia); + const ecommerce = pedido.ecommerce || {}; + + return itens + .map((entry: any) => entry?.item || entry) + .filter(Boolean) + .map((item: any) => ({ + Nome_Cliente: customerName, + Data_Pedido: pickFirst(pedido.data_pedido, summary.data_pedido, summary.data), + Valor_Pedido: orderTotal, + ID_Produto: pickFirst(item.id_produto, item.id, item.codigo), + Descricao_Produto: pickFirst(item.descricao, item.nome, item.codigo, 'Unknown'), + Quantidade: normalizeNumber(item.quantidade), + Valor_Unitario: normalizeNumber(pickFirst(item.valor_unitario, item.valor, item.preco_unitario)), + ID_Pedido: orderId, + Fone_Cliente: customerPhone, + cliente_nome_fantasia: customerFantasyName, + id_vendedor: pickFirst(pedido.id_vendedor), + nome_vendedor: pickFirst(pedido.nome_vendedor), + marketplace: pickFirst(ecommerce.nomeEcommerce, pedido.nome_ecommerce), + canal_venda: pickFirst(ecommerce.canalVenda, pedido.canal_venda), + numero_ecommerce: pickFirst( + pedido.numero_ecommerce, + ecommerce.numeroPedidoEcommerce, + ecommerce.numeroPedidoCanalVenda + ) + })); +} + +async function sendRowsToGraphs(rows: GraphsOrderRow[], orderId: string) { + if (!rows.length) { + console.warn(`[Graphs] Order ${orderId} has no items. Skipping.`); + return; + } + + if (DRY_RUN) { + console.log(`[dry-run] Would send ${rows.length} row(s) for order ${orderId}`); + console.log(JSON.stringify(rows.slice(0, 3), null, 2)); + return; + } + + await axios.post(GRAPHS_API_URL!, rows, { + headers: { + 'Content-Type': 'application/json', + 'x-api-key': GRAPHS_API_KEY! + } + }); + + console.log(`[Graphs] Sent ${rows.length} row(s) for order ${orderId}`); +} + +async function runBackfill() { + if (!TINY_API_TOKEN) { + throw new Error('Missing TINY_API_TOKEN.'); + } + + if (!DRY_RUN && (!GRAPHS_API_URL || !GRAPHS_API_KEY)) { + throw new Error('Missing GRAPHS_API_URL or GRAPHS_API_KEY. Use GRAPHS_BACKFILL_DRY_RUN=true to inspect rows without posting.'); + } + + parseDate(START_DATE); + parseDate(END_DATE); + + const state = loadState(); + let currentDate = state.nextDate; + let processedDays = 0; + let processedOrders = 0; + + console.log(`[config] Start date: ${currentDate}`); + console.log(`[config] End date: ${END_DATE}`); + console.log(`[config] Dry run: ${DRY_RUN ? 'yes' : 'no'}`); + console.log(`[config] Contact fallback: ${CONTACT_FALLBACK ? 'yes' : 'no'}`); + console.log(`[config] State file: ${STATE_FILE}`); + + while (!isAfter(currentDate, END_DATE)) { + if (MAX_DAYS && processedDays >= MAX_DAYS) { + console.log(`[limit] Stopped after ${MAX_DAYS} day(s).`); + break; + } + + console.log(`\n--- Processing ${currentDate} ---`); + const summaries = await fetchOrdersForDate(currentDate); + console.log(`[Tiny] Found ${summaries.length} order(s) for ${currentDate}`); + + for (const summary of summaries) { + if (MAX_ORDERS && processedOrders >= MAX_ORDERS) { + console.log(`[limit] Stopped after ${MAX_ORDERS} order(s).`); + return; + } + + const orderId = pickFirst(summary.id); + if (!orderId) { + console.warn('[Tiny] Summary without order ID. Skipping.'); + continue; + } + + const details = await fetchOrderDetails(orderId); + if (!details) continue; + + const rows = await buildGraphsRows(details, summary); + await sendRowsToGraphs(rows, orderId); + processedOrders += 1; + } + + const completedDate = currentDate; + currentDate = getNextDate(currentDate); + processedDays += 1; + saveState({ + nextDate: currentDate, + lastCompletedDate: completedDate + }); + console.log(`[state] Completed ${completedDate}. Next date: ${currentDate}`); + } + + console.log(`\nDone. Processed ${processedOrders} order(s) across ${processedDays} day(s).`); +} + +runBackfill().catch((error: any) => { + console.error(`Backfill failed: ${error.message}`); + process.exit(1); +}); diff --git a/src/services/graphs.service.ts b/src/services/graphs.service.ts new file mode 100644 index 0000000..b881e6a --- /dev/null +++ b/src/services/graphs.service.ts @@ -0,0 +1,122 @@ +import axios from 'axios'; + +export type GraphsOrderRow = { + Nome_Cliente: string; + Data_Pedido: string; + Valor_Pedido: number; + ID_Produto: string; + Descricao_Produto: string; + Quantidade: number; + Valor_Unitario: number; + ID_Pedido: string; + Fone_Cliente: string; + cliente_nome_fantasia: string; + id_vendedor: string; + nome_vendedor: string; + marketplace: string; + canal_venda: string; + numero_ecommerce: string; +}; + +const normalizeNumber = (value: unknown) => { + if (typeof value === 'number') return Number.isFinite(value) ? value : 0; + if (value === undefined || value === null) return 0; + + const raw = String(value).trim(); + const normalized = raw.includes(',') + ? raw.replace(/\./g, '').replace(',', '.') + : raw; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const normalizePhone = (value: unknown) => { + const raw = String(value || '').trim(); + if (!raw) return ''; + + const digits = raw.replace(/\D+/g, ''); + if (!digits) return ''; + if (digits.startsWith('55') && (digits.length === 12 || digits.length === 13)) return digits; + if (digits.length === 10 || digits.length === 11) return `55${digits}`; + return digits; +}; + +const pickFirst = (...values: unknown[]) => { + for (const value of values) { + const normalized = String(value ?? '').trim(); + if (normalized) return normalized; + } + + return ''; +}; + +export const buildGraphsRowsFromTinyOrder = (pedido: any): GraphsOrderRow[] => { + const cliente = pedido?.cliente || {}; + const ecommerce = pedido?.ecommerce || {}; + const itens = Array.isArray(pedido?.itens) ? pedido.itens : []; + const orderId = pickFirst(pedido?.id); + const orderTotal = normalizeNumber(pickFirst(pedido?.total_pedido, pedido?.valor_total)); + const customerName = pickFirst(cliente.nome, pedido?.nome, 'Unknown'); + const customerPhone = normalizePhone(pickFirst(cliente.celular, cliente.telefone, cliente.fone)); + const customerFantasyName = pickFirst(cliente.nome_fantasia, cliente.fantasia); + + return itens + .map((entry: any) => entry?.item || entry) + .filter(Boolean) + .map((item: any) => ({ + Nome_Cliente: customerName, + Data_Pedido: pickFirst(pedido?.data_pedido), + Valor_Pedido: orderTotal, + ID_Produto: pickFirst(item.id_produto, item.id, item.codigo), + Descricao_Produto: pickFirst(item.descricao, item.nome, item.codigo, 'Unknown'), + Quantidade: normalizeNumber(item.quantidade), + Valor_Unitario: normalizeNumber(pickFirst(item.valor_unitario, item.valor, item.preco_unitario)), + ID_Pedido: orderId, + Fone_Cliente: customerPhone, + cliente_nome_fantasia: customerFantasyName, + id_vendedor: pickFirst(pedido?.id_vendedor), + nome_vendedor: pickFirst(pedido?.nome_vendedor), + marketplace: pickFirst(ecommerce.nomeEcommerce, pedido?.nome_ecommerce), + canal_venda: pickFirst(ecommerce.canalVenda, pedido?.canal_venda), + numero_ecommerce: pickFirst( + pedido?.numero_ecommerce, + ecommerce.numeroPedidoEcommerce, + ecommerce.numeroPedidoCanalVenda + ) + })); +}; + +export const sendRowsToGraphs = async (rows: GraphsOrderRow[], orderId: string): Promise => { + const graphsApiUrl = process.env.GRAPHS_API_URL || process.env.NEXSTAR_GRAPHS_API_URL; + const graphsApiKey = process.env.GRAPHS_API_KEY || process.env.NEXSTAR_GRAPHS_API_KEY; + + if (!graphsApiUrl || !graphsApiKey) { + return; + } + + if (!rows.length) { + console.warn(`[Graphs] Order ${orderId} has no items. Skipping.`); + return; + } + + try { + await axios.post(graphsApiUrl, rows, { + headers: { + 'Content-Type': 'application/json', + 'x-api-key': graphsApiKey + } + }); + console.log(`[Graphs] Sent ${rows.length} row(s) for order ${orderId}.`); + } catch (error: any) { + console.error(`[Graphs] Failed to send order ${orderId}: ${error.message}`); + if (error.response) { + console.error('[Graphs] Response:', error.response.status, error.response.data); + } + } +}; + +export const sendTinyOrderToGraphs = async (pedido: any): Promise => { + const orderId = pickFirst(pedido?.id); + const rows = buildGraphsRowsFromTinyOrder(pedido); + await sendRowsToGraphs(rows, orderId); +};