feat: sync Tiny orders directly to Graphs
All checks were successful
Build and Deploy / build-and-push (push) Successful in 59s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 59s
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
|
graphs_backfill_state.json
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
|
"backfill:graphs": "tsx src/scripts/backfill-graphs.ts",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import { sendToN8n } from '../services/n8n.service';
|
import { sendToN8n } from '../services/n8n.service';
|
||||||
|
import { sendTinyOrderToGraphs } from '../services/graphs.service';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -131,6 +132,11 @@ export const handleTinyOrderUpdate = async (req: Request, res: Response): Promis
|
|||||||
dispatchPromises.push(sendToN8n(finalPayload, graphsUrl));
|
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
|
// Fire them all in parallel so one does not block the other
|
||||||
await Promise.allSettled(dispatchPromises);
|
await Promise.allSettled(dispatchPromises);
|
||||||
console.log('All n8n dispatch attempts completed.');
|
console.log('All n8n dispatch attempts completed.');
|
||||||
|
|||||||
50
src/index.ts
50
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.' });
|
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
|
// Hidden endpoint to download the stock CSV log directly from the browser
|
||||||
app.get('/api/stock-logs/download', (req: Request, res: Response) => {
|
app.get('/api/stock-logs/download', (req: Request, res: Response) => {
|
||||||
const expectedToken = process.env.TINY_WEBHOOK_SECRET;
|
const expectedToken = process.env.TINY_WEBHOOK_SECRET;
|
||||||
|
|||||||
430
src/scripts/backfill-graphs.ts
Normal file
430
src/scripts/backfill-graphs.ts
Normal 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);
|
||||||
|
});
|
||||||
122
src/services/graphs.service.ts
Normal file
122
src/services/graphs.service.ts
Normal 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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user