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

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);
});