Compare commits
4 Commits
10d7048497
...
44cfdbb4c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44cfdbb4c9 | ||
|
|
1d24831aa7 | ||
|
|
ae41ae4a55 | ||
|
|
c315265d16 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules
|
||||
dist
|
||||
.env
|
||||
graphs_backfill_state.json
|
||||
graphs_backfill_stop.json
|
||||
|
||||
@@ -19,10 +19,11 @@ services:
|
||||
- GRAPHS_API_URL=${GRAPHS_API_URL}
|
||||
- GRAPHS_API_KEY=${GRAPHS_API_KEY}
|
||||
- GRAPHS_BACKFILL_CONTACT_FALLBACK=${GRAPHS_BACKFILL_CONTACT_FALLBACK}
|
||||
- TINY_MIN_REQUEST_INTERVAL_MS=${TINY_MIN_REQUEST_INTERVAL_MS}
|
||||
- TINY_BLOCK_RETRY_DELAY_MS=${TINY_BLOCK_RETRY_DELAY_MS}
|
||||
- TINY_BLOCK_MAX_RETRIES=${TINY_BLOCK_MAX_RETRIES}
|
||||
- GRAPHS_BACKFILL_ORDER_DELAY_MS=${GRAPHS_BACKFILL_ORDER_DELAY_MS}
|
||||
- GRAPHS_BACKFILL_TINY_BLOCK_DELAY_MS=${GRAPHS_BACKFILL_TINY_BLOCK_DELAY_MS}
|
||||
- GRAPHS_BACKFILL_MAX_TINY_BLOCK_RETRIES=${GRAPHS_BACKFILL_MAX_TINY_BLOCK_RETRIES}
|
||||
- TINY_FETCH_SELLER_DETAILS=${TINY_FETCH_SELLER_DETAILS}
|
||||
- TINY_ORDER_DETAILS_CACHE_TTL_MS=${TINY_ORDER_DETAILS_CACHE_TTL_MS}
|
||||
# Optional: If you use a shared Docker network for Nginx Proxy Manager, you can attach it here:
|
||||
# networks:
|
||||
# - nginx-proxy-manager-network
|
||||
|
||||
@@ -1,10 +1,52 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { sendToN8n } from '../services/n8n.service';
|
||||
import { sendTinyOrderToGraphs } from '../services/graphs.service';
|
||||
import { tinyPost } from '../services/tiny-api.service';
|
||||
import axios from 'axios';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
type CachedOrderDetails = {
|
||||
expiresAt: number;
|
||||
fullOrderDetails: any;
|
||||
statusProcessamento: string;
|
||||
whatsappVendedor: string;
|
||||
};
|
||||
|
||||
const orderDetailsCache = new Map<string, CachedOrderDetails>();
|
||||
const configuredCacheTtlMs = Number(process.env.TINY_ORDER_DETAILS_CACHE_TTL_MS || 120000);
|
||||
const ORDER_DETAILS_CACHE_TTL_MS = Number.isFinite(configuredCacheTtlMs) ? configuredCacheTtlMs : 120000;
|
||||
|
||||
function pruneExpiredOrderDetailsCache() {
|
||||
const now = Date.now();
|
||||
for (const [orderId, cached] of orderDetailsCache.entries()) {
|
||||
if (now > cached.expiresAt) {
|
||||
orderDetailsCache.delete(orderId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedOrderDetails(orderId: string) {
|
||||
const cached = orderDetailsCache.get(orderId);
|
||||
if (!cached) return null;
|
||||
|
||||
if (Date.now() > cached.expiresAt) {
|
||||
orderDetailsCache.delete(orderId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
function setCachedOrderDetails(orderId: string, cached: Omit<CachedOrderDetails, 'expiresAt'>) {
|
||||
if (ORDER_DETAILS_CACHE_TTL_MS <= 0) return;
|
||||
|
||||
pruneExpiredOrderDetailsCache();
|
||||
orderDetailsCache.set(orderId, {
|
||||
...cached,
|
||||
expiresAt: Date.now() + ORDER_DETAILS_CACHE_TTL_MS
|
||||
});
|
||||
}
|
||||
|
||||
export const handleTinyOrderUpdate = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// 1. Security Check: Verify token from Tiny
|
||||
@@ -42,7 +84,13 @@ export const handleTinyOrderUpdate = async (req: Request, res: Response): Promis
|
||||
let statusProcessamento = "";
|
||||
let whatsappVendedor = "";
|
||||
|
||||
if (tinyApiToken) {
|
||||
const cachedDetails = getCachedOrderDetails(String(orderId));
|
||||
if (cachedDetails) {
|
||||
fullOrderDetails = cachedDetails.fullOrderDetails;
|
||||
statusProcessamento = cachedDetails.statusProcessamento;
|
||||
whatsappVendedor = cachedDetails.whatsappVendedor;
|
||||
console.log(`Using cached order details for Order ID: ${orderId}.`);
|
||||
} else if (tinyApiToken) {
|
||||
try {
|
||||
console.log(`Fetching full details for Order ID: ${orderId} from Tiny API...`);
|
||||
const params = new URLSearchParams();
|
||||
@@ -50,7 +98,9 @@ export const handleTinyOrderUpdate = async (req: Request, res: Response): Promis
|
||||
params.append('id', orderId);
|
||||
params.append('formato', 'JSON');
|
||||
|
||||
const apiResponse = await tinyPost('pedido.obter.php', params);
|
||||
const apiResponse = await axios.post('https://api.tiny.com.br/api2/pedido.obter.php', params, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
|
||||
if (apiResponse.data?.retorno?.status === 'OK') {
|
||||
fullOrderDetails = apiResponse.data.retorno.pedido;
|
||||
@@ -59,14 +109,16 @@ export const handleTinyOrderUpdate = async (req: Request, res: Response): Promis
|
||||
|
||||
// OPTION B: Fetch Vendor's WhatsApp
|
||||
const idVendedor = fullOrderDetails.id_vendedor;
|
||||
if (process.env.TINY_FETCH_SELLER_DETAILS !== 'false' && idVendedor && idVendedor !== "0") {
|
||||
if (process.env.TINY_FETCH_SELLER_DETAILS === 'true' && idVendedor && idVendedor !== "0") {
|
||||
console.log(`Fetching seller details for Seller ID: ${idVendedor}...`);
|
||||
const vendorParams = new URLSearchParams();
|
||||
vendorParams.append('token', tinyApiToken);
|
||||
vendorParams.append('id', idVendedor);
|
||||
vendorParams.append('formato', 'JSON');
|
||||
|
||||
const vendorResponse = await tinyPost('contato.obter.php', vendorParams);
|
||||
const vendorResponse = await axios.post('https://api.tiny.com.br/api2/contato.obter.php', vendorParams, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
|
||||
if (vendorResponse.data?.retorno?.status === 'OK') {
|
||||
const contato = vendorResponse.data.retorno.contato;
|
||||
@@ -76,6 +128,12 @@ export const handleTinyOrderUpdate = async (req: Request, res: Response): Promis
|
||||
console.error('Failed to fetch seller details:', JSON.stringify(vendorResponse.data?.retorno?.erros || 'Unknown API error'));
|
||||
}
|
||||
}
|
||||
|
||||
setCachedOrderDetails(String(orderId), {
|
||||
fullOrderDetails,
|
||||
statusProcessamento,
|
||||
whatsappVendedor
|
||||
});
|
||||
} else {
|
||||
console.error('Tiny API returned an error:', apiResponse.data?.retorno?.erros || 'Unknown error');
|
||||
}
|
||||
|
||||
63
src/index.ts
63
src/index.ts
@@ -3,6 +3,7 @@ import dotenv from 'dotenv';
|
||||
import webhookRoutes from './routes/webhook.route';
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -50,6 +51,7 @@ app.get('/api/trigger-graphs-backfill', (req: Request, res: Response) => {
|
||||
|
||||
const scriptPath = path.join(__dirname, 'scripts', 'backfill-graphs.js');
|
||||
const env = { ...process.env };
|
||||
const stopFile = env.GRAPHS_BACKFILL_STOP_FILE || path.join(process.cwd(), 'graphs_backfill_stop.json');
|
||||
|
||||
const optionalEnvMap: Record<string, string> = {
|
||||
start: 'GRAPHS_BACKFILL_START_DATE',
|
||||
@@ -57,7 +59,10 @@ app.get('/api/trigger-graphs-backfill', (req: Request, res: Response) => {
|
||||
dryRun: 'GRAPHS_BACKFILL_DRY_RUN',
|
||||
maxOrders: 'GRAPHS_BACKFILL_MAX_ORDERS',
|
||||
maxDays: 'GRAPHS_BACKFILL_MAX_DAYS',
|
||||
contactFallback: 'GRAPHS_BACKFILL_CONTACT_FALLBACK'
|
||||
contactFallback: 'GRAPHS_BACKFILL_CONTACT_FALLBACK',
|
||||
orderDelayMs: 'GRAPHS_BACKFILL_ORDER_DELAY_MS',
|
||||
blockDelayMs: 'GRAPHS_BACKFILL_TINY_BLOCK_DELAY_MS',
|
||||
maxTinyBlockRetries: 'GRAPHS_BACKFILL_MAX_TINY_BLOCK_RETRIES'
|
||||
};
|
||||
|
||||
for (const [queryKey, envKey] of Object.entries(optionalEnvMap)) {
|
||||
@@ -67,6 +72,10 @@ app.get('/api/trigger-graphs-backfill', (req: Request, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(stopFile)) {
|
||||
fs.rmSync(stopFile, { force: true });
|
||||
}
|
||||
|
||||
const child = spawn('node', [scriptPath], {
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
@@ -86,14 +95,60 @@ app.get('/api/trigger-graphs-backfill', (req: Request, res: Response) => {
|
||||
maxOrders: env.GRAPHS_BACKFILL_MAX_ORDERS || null,
|
||||
maxDays: env.GRAPHS_BACKFILL_MAX_DAYS || null,
|
||||
contactFallback: env.GRAPHS_BACKFILL_CONTACT_FALLBACK || 'true',
|
||||
orderDelayMs: env.GRAPHS_BACKFILL_ORDER_DELAY_MS || '6000',
|
||||
blockDelayMs: env.GRAPHS_BACKFILL_TINY_BLOCK_DELAY_MS || '600000',
|
||||
maxTinyBlockRetries: env.GRAPHS_BACKFILL_MAX_TINY_BLOCK_RETRIES || null,
|
||||
graphsApiUrlConfigured: Boolean(env.GRAPHS_API_URL || env.NEXSTAR_GRAPHS_API_URL),
|
||||
graphsApiKeyConfigured: Boolean(env.GRAPHS_API_KEY || env.NEXSTAR_GRAPHS_API_KEY),
|
||||
tinyMinRequestIntervalMs: env.TINY_MIN_REQUEST_INTERVAL_MS || null,
|
||||
tinyFetchSellerDetails: env.TINY_FETCH_SELLER_DETAILS || 'true'
|
||||
graphsApiKeyConfigured: Boolean(env.GRAPHS_API_KEY || env.NEXSTAR_GRAPHS_API_KEY)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/graphs-backfill-status', (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 stateFile = process.env.GRAPHS_BACKFILL_STATE_FILE || path.join(process.cwd(), 'graphs_backfill_state.json');
|
||||
const stopFile = process.env.GRAPHS_BACKFILL_STOP_FILE || path.join(process.cwd(), 'graphs_backfill_stop.json');
|
||||
let state = null;
|
||||
|
||||
if (fs.existsSync(stateFile)) {
|
||||
try {
|
||||
state = JSON.parse(fs.readFileSync(stateFile, 'utf-8'));
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: `Could not read backfill state: ${error.message}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
status: state?.status || 'unknown',
|
||||
stopRequested: fs.existsSync(stopFile),
|
||||
state
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/stop-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 stopFile = process.env.GRAPHS_BACKFILL_STOP_FILE || path.join(process.cwd(), 'graphs_backfill_stop.json');
|
||||
fs.writeFileSync(stopFile, JSON.stringify({
|
||||
requestedAt: new Date().toISOString()
|
||||
}, null, 2));
|
||||
|
||||
res.status(200).json({
|
||||
status: 'STOP_REQUESTED',
|
||||
message: 'Graphs backfill will stop after the current wait/request finishes.'
|
||||
});
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -2,7 +2,6 @@ import axios from 'axios';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { tinyPost } from '../services/tiny-api.service';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -45,6 +44,12 @@ type ContactInfo = {
|
||||
type BackfillState = {
|
||||
nextDate: string;
|
||||
lastCompletedDate?: string;
|
||||
status?: 'running' | 'stopped' | 'completed';
|
||||
currentDate?: string;
|
||||
lastOrderId?: string;
|
||||
processedDays?: number;
|
||||
processedOrders?: number;
|
||||
lastMessage?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
@@ -56,19 +61,31 @@ 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_MIN_REQUEST_INTERVAL_MS = Number(process.env.TINY_MIN_REQUEST_INTERVAL_MS || 0);
|
||||
const USE_SHARED_RATE_LIMIT = TINY_MIN_REQUEST_INTERVAL_MS > 0;
|
||||
const TINY_SEARCH_DELAY_MS = USE_SHARED_RATE_LIMIT ? 0 : Number(process.env.TINY_SEARCH_DELAY_MS || 1000);
|
||||
const TINY_DETAIL_DELAY_MS = USE_SHARED_RATE_LIMIT ? 0 : Number(process.env.TINY_DETAIL_DELAY_MS || 1500);
|
||||
const TINY_CONTACT_DELAY_MS = USE_SHARED_RATE_LIMIT ? 0 : Number(process.env.TINY_CONTACT_DELAY_MS || TINY_DETAIL_DELAY_MS);
|
||||
const numberEnv = (name: string, fallback: number) => {
|
||||
const value = Number(process.env[name] ?? fallback);
|
||||
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||
};
|
||||
const MAX_DAYS = numberEnv('GRAPHS_BACKFILL_MAX_DAYS', 0);
|
||||
const MAX_ORDERS = numberEnv('GRAPHS_BACKFILL_MAX_ORDERS', 0);
|
||||
const TINY_SEARCH_DELAY_MS = numberEnv('TINY_SEARCH_DELAY_MS', 1000);
|
||||
const TINY_DETAIL_DELAY_MS = numberEnv('TINY_DETAIL_DELAY_MS', 1500);
|
||||
const TINY_CONTACT_DELAY_MS = numberEnv('TINY_CONTACT_DELAY_MS', TINY_DETAIL_DELAY_MS);
|
||||
const ORDER_DELAY_MS = numberEnv('GRAPHS_BACKFILL_ORDER_DELAY_MS', 6000);
|
||||
const TINY_BLOCK_DELAY_MS = numberEnv('GRAPHS_BACKFILL_TINY_BLOCK_DELAY_MS', 600000);
|
||||
const MAX_TINY_BLOCK_RETRIES = numberEnv('GRAPHS_BACKFILL_MAX_TINY_BLOCK_RETRIES', 0);
|
||||
const STATE_FILE = process.env.GRAPHS_BACKFILL_STATE_FILE || path.join(process.cwd(), 'graphs_backfill_state.json');
|
||||
const STOP_FILE = process.env.GRAPHS_BACKFILL_STOP_FILE || path.join(process.cwd(), 'graphs_backfill_stop.json');
|
||||
|
||||
const contactCache = new Map<string, ContactInfo | null>();
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
class BackfillStoppedError extends Error {
|
||||
constructor() {
|
||||
super('Graphs backfill stopped by request.');
|
||||
}
|
||||
}
|
||||
|
||||
function getTodayDate() {
|
||||
const date = new Date();
|
||||
return formatDate(date);
|
||||
@@ -133,6 +150,26 @@ function saveState(state: BackfillState) {
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
function assertNotStopped() {
|
||||
if (fs.existsSync(STOP_FILE)) {
|
||||
throw new BackfillStoppedError();
|
||||
}
|
||||
}
|
||||
|
||||
async function sleepWithStop(ms: number, reason: string) {
|
||||
if (ms <= 0) return;
|
||||
|
||||
const startedAt = Date.now();
|
||||
let remaining = ms;
|
||||
console.log(`[wait] Waiting ${ms}ms before ${reason}.`);
|
||||
|
||||
while (remaining > 0) {
|
||||
assertNotStopped();
|
||||
await sleep(Math.min(remaining, 1000));
|
||||
remaining = ms - (Date.now() - startedAt);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeNumber(value: unknown) {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
|
||||
if (value === undefined || value === null) return 0;
|
||||
@@ -170,6 +207,46 @@ function pickFirst(...values: unknown[]) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function getTinyErrors(data: any) {
|
||||
const errors = data?.retorno?.erros;
|
||||
if (!errors) return [];
|
||||
return Array.isArray(errors) ? errors : [errors];
|
||||
}
|
||||
|
||||
function isTinyApiBlocked(data: any) {
|
||||
return getTinyErrors(data).some((entry: any) => {
|
||||
const message = String(entry?.erro || entry || '').toLowerCase();
|
||||
return message.includes('api bloqueada') || message.includes('excedido o número de acessos');
|
||||
});
|
||||
}
|
||||
|
||||
async function tinyPost(servicePhp: string, params: URLSearchParams) {
|
||||
let blockedAttempts = 0;
|
||||
|
||||
while (true) {
|
||||
assertNotStopped();
|
||||
|
||||
const response = await axios.post(`https://api.tiny.com.br/api2/${servicePhp}`, params, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
|
||||
if (!isTinyApiBlocked(response.data)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
blockedAttempts += 1;
|
||||
if (MAX_TINY_BLOCK_RETRIES && blockedAttempts > MAX_TINY_BLOCK_RETRIES) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const retryLabel = MAX_TINY_BLOCK_RETRIES
|
||||
? `${blockedAttempts}/${MAX_TINY_BLOCK_RETRIES}`
|
||||
: String(blockedAttempts);
|
||||
console.warn(`[Tiny] API blocked on ${servicePhp}. Pausing backfill for ${TINY_BLOCK_DELAY_MS}ms before retry ${retryLabel}.`);
|
||||
await sleepWithStop(TINY_BLOCK_DELAY_MS, 'retrying Tiny API after block');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOrdersForDate(dateStr: string, page = 1): Promise<TinyOrderSummary[]> {
|
||||
console.log(`[Tiny] Searching orders for ${dateStr}, page ${page}`);
|
||||
|
||||
@@ -181,7 +258,7 @@ async function fetchOrdersForDate(dateStr: string, page = 1): Promise<TinyOrderS
|
||||
params.append('pagina', String(page));
|
||||
|
||||
const response = await tinyPost('pedidos.pesquisa.php', params);
|
||||
await sleep(TINY_SEARCH_DELAY_MS);
|
||||
await sleepWithStop(TINY_SEARCH_DELAY_MS, 'next Tiny search request');
|
||||
|
||||
const retorno = response.data?.retorno;
|
||||
if (retorno?.status === 'OK') {
|
||||
@@ -211,7 +288,7 @@ async function fetchOrderDetails(orderId: string): Promise<TinyOrderDetails | nu
|
||||
|
||||
try {
|
||||
const response = await tinyPost('pedido.obter.php', params);
|
||||
await sleep(TINY_DETAIL_DELAY_MS);
|
||||
await sleepWithStop(TINY_DETAIL_DELAY_MS, 'next Tiny order detail request');
|
||||
|
||||
if (response.data?.retorno?.status === 'OK') {
|
||||
return {
|
||||
@@ -223,8 +300,9 @@ async function fetchOrderDetails(orderId: string): Promise<TinyOrderDetails | nu
|
||||
console.error(`[Tiny] Failed to fetch order ${orderId}:`, JSON.stringify(response.data?.retorno?.erros || 'Unknown error'));
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
if (error instanceof BackfillStoppedError) throw error;
|
||||
console.error(`[Tiny] Request failed for order ${orderId}: ${error.message}`);
|
||||
await sleep(5000);
|
||||
await sleepWithStop(5000, 'continuing after Tiny request failure');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -247,7 +325,7 @@ async function fetchContactInfo(cliente: any): Promise<ContactInfo | null> {
|
||||
if (cpfCnpj) searchParams.append('cpf_cnpj', cpfCnpj);
|
||||
|
||||
const searchResponse = await tinyPost('contatos.pesquisa.php', searchParams);
|
||||
await sleep(TINY_CONTACT_DELAY_MS);
|
||||
await sleepWithStop(TINY_CONTACT_DELAY_MS, 'next Tiny contact request');
|
||||
|
||||
const contatoResumo = searchResponse.data?.retorno?.contatos?.[0]?.contato;
|
||||
const contatoId = contatoResumo?.id;
|
||||
@@ -262,7 +340,7 @@ async function fetchContactInfo(cliente: any): Promise<ContactInfo | null> {
|
||||
detailParams.append('id', String(contatoId));
|
||||
|
||||
const detailResponse = await tinyPost('contato.obter.php', detailParams);
|
||||
await sleep(TINY_CONTACT_DELAY_MS);
|
||||
await sleepWithStop(TINY_CONTACT_DELAY_MS, 'next Tiny contact request');
|
||||
|
||||
if (detailResponse.data?.retorno?.status !== 'OK') {
|
||||
contactCache.set(cacheKey, null);
|
||||
@@ -278,6 +356,7 @@ async function fetchContactInfo(cliente: any): Promise<ContactInfo | null> {
|
||||
contactCache.set(cacheKey, info);
|
||||
return info;
|
||||
} catch (error: any) {
|
||||
if (error instanceof BackfillStoppedError) throw error;
|
||||
console.error(`[Tiny] Contact fallback failed for ${name || cpfCnpj}: ${error.message}`);
|
||||
contactCache.set(cacheKey, null);
|
||||
return null;
|
||||
@@ -369,22 +448,32 @@ async function runBackfill() {
|
||||
|
||||
const state = loadState();
|
||||
let currentDate = state.nextDate;
|
||||
let lastCompletedDate = state.lastCompletedDate;
|
||||
let processedDays = 0;
|
||||
let processedOrders = 0;
|
||||
const orderPace = ORDER_DELAY_MS ? `~${Math.floor(60000 / ORDER_DELAY_MS)}/minute` : 'unlimited';
|
||||
|
||||
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'}`);
|
||||
if (USE_SHARED_RATE_LIMIT) {
|
||||
const requestsPerMinute = Math.floor(60000 / TINY_MIN_REQUEST_INTERVAL_MS);
|
||||
console.log(`[config] Shared Tiny rate limit: 1 request every ${TINY_MIN_REQUEST_INTERVAL_MS}ms (~${requestsPerMinute}/minute)`);
|
||||
} else {
|
||||
console.log('[config] Shared Tiny rate limit: disabled');
|
||||
}
|
||||
console.log(`[config] Order pace: 1 order every ${ORDER_DELAY_MS}ms (${orderPace})`);
|
||||
console.log(`[config] Tiny block pause: ${TINY_BLOCK_DELAY_MS}ms`);
|
||||
console.log(`[config] State file: ${STATE_FILE}`);
|
||||
console.log(`[config] Stop file: ${STOP_FILE}`);
|
||||
|
||||
saveState({
|
||||
...state,
|
||||
status: 'running',
|
||||
currentDate,
|
||||
processedDays,
|
||||
processedOrders,
|
||||
lastMessage: 'Backfill started'
|
||||
});
|
||||
|
||||
while (!isAfter(currentDate, END_DATE)) {
|
||||
assertNotStopped();
|
||||
|
||||
if (MAX_DAYS && processedDays >= MAX_DAYS) {
|
||||
console.log(`[limit] Stopped after ${MAX_DAYS} day(s).`);
|
||||
break;
|
||||
@@ -395,8 +484,19 @@ async function runBackfill() {
|
||||
console.log(`[Tiny] Found ${summaries.length} order(s) for ${currentDate}`);
|
||||
|
||||
for (const summary of summaries) {
|
||||
assertNotStopped();
|
||||
|
||||
if (MAX_ORDERS && processedOrders >= MAX_ORDERS) {
|
||||
console.log(`[limit] Stopped after ${MAX_ORDERS} order(s).`);
|
||||
saveState({
|
||||
nextDate: currentDate,
|
||||
lastCompletedDate,
|
||||
status: 'completed',
|
||||
currentDate,
|
||||
processedDays,
|
||||
processedOrders,
|
||||
lastMessage: `Stopped after ${MAX_ORDERS} order(s)`
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -412,22 +512,63 @@ async function runBackfill() {
|
||||
const rows = await buildGraphsRows(details, summary);
|
||||
await sendRowsToGraphs(rows, orderId);
|
||||
processedOrders += 1;
|
||||
|
||||
saveState({
|
||||
nextDate: currentDate,
|
||||
lastCompletedDate,
|
||||
status: 'running',
|
||||
currentDate,
|
||||
lastOrderId: orderId,
|
||||
processedDays,
|
||||
processedOrders,
|
||||
lastMessage: `Processed order ${orderId}`
|
||||
});
|
||||
|
||||
if (!MAX_ORDERS || processedOrders < MAX_ORDERS) {
|
||||
await sleepWithStop(ORDER_DELAY_MS, 'next order');
|
||||
}
|
||||
}
|
||||
|
||||
const completedDate = currentDate;
|
||||
currentDate = getNextDate(currentDate);
|
||||
lastCompletedDate = completedDate;
|
||||
processedDays += 1;
|
||||
saveState({
|
||||
nextDate: currentDate,
|
||||
lastCompletedDate: completedDate
|
||||
lastCompletedDate,
|
||||
status: 'running',
|
||||
currentDate,
|
||||
processedDays,
|
||||
processedOrders,
|
||||
lastMessage: `Completed ${completedDate}`
|
||||
});
|
||||
console.log(`[state] Completed ${completedDate}. Next date: ${currentDate}`);
|
||||
}
|
||||
|
||||
saveState({
|
||||
nextDate: currentDate,
|
||||
lastCompletedDate,
|
||||
status: 'completed',
|
||||
currentDate,
|
||||
processedDays,
|
||||
processedOrders,
|
||||
lastMessage: 'Backfill completed'
|
||||
});
|
||||
console.log(`\nDone. Processed ${processedOrders} order(s) across ${processedDays} day(s).`);
|
||||
}
|
||||
|
||||
runBackfill().catch((error: any) => {
|
||||
if (error instanceof BackfillStoppedError) {
|
||||
console.log('Backfill stopped by request.');
|
||||
const state = loadState();
|
||||
saveState({
|
||||
...state,
|
||||
status: 'stopped',
|
||||
lastMessage: 'Backfill stopped by request'
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`Backfill failed: ${error.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import axios from 'axios';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { tinyPost } from '../services/tiny-api.service';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -51,7 +50,9 @@ const fetchOrdersForDate = async (dateStr: string, page: number = 1): Promise<an
|
||||
params.append('pagina', page.toString());
|
||||
|
||||
try {
|
||||
const response = await tinyPost('pedidos.pesquisa.php', params);
|
||||
const response = await axios.post('https://api.tiny.com.br/api2/pedidos.pesquisa.php', params, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
|
||||
await sleep(1000); // 1 second delay
|
||||
|
||||
@@ -91,7 +92,9 @@ const fetchOrderDetails = async (orderId: string) => {
|
||||
params.append('formato', 'JSON');
|
||||
|
||||
try {
|
||||
const response = await tinyPost('pedido.obter.php', params);
|
||||
const response = await axios.post('https://api.tiny.com.br/api2/pedido.obter.php', params, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
|
||||
await sleep(1500); // 1.5 second delay. This is the heavy part.
|
||||
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
const DEFAULT_LOCK_DIR = path.join(os.tmpdir(), 'api-tiny-n8n-rate-limit.lock');
|
||||
const DEFAULT_STATE_FILE = path.join(os.tmpdir(), 'api-tiny-n8n-rate-limit.json');
|
||||
const LOCK_STALE_MS = 30000;
|
||||
const DEFAULT_BLOCK_RETRY_DELAY_MS = 120000;
|
||||
const DEFAULT_BLOCK_MAX_RETRIES = 30;
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
const getPositiveNumberEnv = (name: string, fallback: number) => {
|
||||
const configured = Number(process.env[name] || fallback);
|
||||
return Number.isFinite(configured) && configured > 0 ? configured : 0;
|
||||
};
|
||||
|
||||
const getMinIntervalMs = () => getPositiveNumberEnv('TINY_MIN_REQUEST_INTERVAL_MS', 0);
|
||||
const getBlockRetryDelayMs = () => getPositiveNumberEnv('TINY_BLOCK_RETRY_DELAY_MS', DEFAULT_BLOCK_RETRY_DELAY_MS);
|
||||
const getBlockMaxRetries = () => getPositiveNumberEnv('TINY_BLOCK_MAX_RETRIES', DEFAULT_BLOCK_MAX_RETRIES);
|
||||
const getLockDir = () => process.env.TINY_RATE_LIMIT_LOCK_DIR || DEFAULT_LOCK_DIR;
|
||||
const getStateFile = () => process.env.TINY_RATE_LIMIT_STATE_FILE || DEFAULT_STATE_FILE;
|
||||
|
||||
const readLastRequestAt = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(getStateFile(), 'utf-8'));
|
||||
return Number(parsed.lastRequestAt || 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
const writeLastRequestAt = (timestamp: number) => {
|
||||
fs.writeFileSync(getStateFile(), JSON.stringify({ lastRequestAt: timestamp }, null, 2));
|
||||
};
|
||||
|
||||
const removeStaleLock = (lockDir: string) => {
|
||||
try {
|
||||
const stats = fs.statSync(lockDir);
|
||||
if (Date.now() - stats.mtimeMs > LOCK_STALE_MS) {
|
||||
fs.rmSync(lockDir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// Lock does not exist or cannot be inspected. The acquire loop will retry.
|
||||
}
|
||||
};
|
||||
|
||||
const acquireLock = async () => {
|
||||
const lockDir = getLockDir();
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
fs.mkdirSync(lockDir);
|
||||
return lockDir;
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'EEXIST') throw error;
|
||||
removeStaleLock(lockDir);
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const releaseLock = (lockDir: string) => {
|
||||
fs.rmSync(lockDir, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
const waitForTinySlot = async () => {
|
||||
const minIntervalMs = getMinIntervalMs();
|
||||
if (!minIntervalMs) return;
|
||||
|
||||
const lockDir = await acquireLock();
|
||||
|
||||
try {
|
||||
const lastRequestAt = readLastRequestAt();
|
||||
const elapsed = Date.now() - lastRequestAt;
|
||||
const waitMs = Math.max(0, minIntervalMs - elapsed);
|
||||
|
||||
if (waitMs > 0) {
|
||||
console.log(`[Tiny Rate Limit] Waiting ${waitMs}ms before next Tiny API request.`);
|
||||
await sleep(waitMs);
|
||||
}
|
||||
|
||||
writeLastRequestAt(Date.now());
|
||||
} finally {
|
||||
releaseLock(lockDir);
|
||||
}
|
||||
};
|
||||
|
||||
const getTinyErrors = (data: any) => {
|
||||
const erros = data?.retorno?.erros;
|
||||
if (!erros) return [];
|
||||
return Array.isArray(erros) ? erros : [erros];
|
||||
};
|
||||
|
||||
const isTinyApiBlocked = (data: any) => {
|
||||
return getTinyErrors(data).some((entry: any) => {
|
||||
const message = String(entry?.erro || entry || '').toLowerCase();
|
||||
return message.includes('api bloqueada') || message.includes('excedido o número de acessos');
|
||||
});
|
||||
};
|
||||
|
||||
export const tinyPost = async (servicePhp: string, params: URLSearchParams) => {
|
||||
let attempt = 0;
|
||||
|
||||
while (true) {
|
||||
await waitForTinySlot();
|
||||
|
||||
const response = await axios.post(`https://api.tiny.com.br/api2/${servicePhp}`, params, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
|
||||
if (!isTinyApiBlocked(response.data)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
attempt += 1;
|
||||
const maxRetries = getBlockMaxRetries();
|
||||
if (attempt > maxRetries) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const delayMs = getBlockRetryDelayMs();
|
||||
console.warn(`[Tiny Rate Limit] Tiny blocked ${servicePhp}. Waiting ${delayMs}ms before retry ${attempt}/${maxRetries}.`);
|
||||
await sleep(delayMs);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user