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,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.');
|
||||
|
||||
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.' });
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
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