feat: sync Tiny orders directly to Graphs
All checks were successful
Build and Deploy / build-and-push (push) Successful in 59s

This commit is contained in:
Cauê Faleiros
2026-06-22 15:49:22 -03:00
parent 9a9e9bedf2
commit 3234ab6160
6 changed files with 611 additions and 1 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules
dist
.env
graphs_backfill_state.json

View File

@@ -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": [],

View File

@@ -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.');

View File

@@ -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<string, string> = {
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;

View File

@@ -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<string, ContactInfo | null>();
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<TinyOrderSummary[]> {
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<TinyOrderDetails | null> {
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<ContactInfo | null> {
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<GraphsOrderRow[]> {
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);
});

View File

@@ -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<void> => {
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<void> => {
const orderId = pickFirst(pedido?.id);
const rows = buildGraphsRowsFromTinyOrder(pedido);
await sendRowsToGraphs(rows, orderId);
};