first commit

This commit is contained in:
Cauê Faleiros
2026-07-02 12:54:31 -03:00
commit cd6b4c93f0
17 changed files with 1409 additions and 0 deletions

154
src/config.js Normal file
View File

@@ -0,0 +1,154 @@
require('dotenv').config({ quiet: true });
const parseBoolean = (value, fallback = false) => {
if (value === undefined || value === null || value === '') return fallback;
return ['1', 'true', 'yes', 'y', 'sim'].includes(String(value).trim().toLowerCase());
};
const parseInteger = (value, fallback) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
};
const parseDate = (value, name) => {
if (!value) return null;
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new Error(`${name} must use YYYY-MM-DD format`);
}
const date = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(date.getTime())) {
throw new Error(`${name} is not a valid date`);
}
return value;
};
const parseEnum = (value, allowedValues, fallback, name) => {
const normalized = String(value || fallback).trim().toUpperCase();
if (!allowedValues.includes(normalized)) {
throw new Error(`${name} must be one of: ${allowedValues.join(', ')}`);
}
return normalized;
};
const toIsoDateInTimeZone = (date = new Date(), timeZone = 'America/Sao_Paulo') => {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(date);
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${values.year}-${values.month}-${values.day}`;
};
const addDays = (isoDate, days) => {
const [year, month, day] = isoDate.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day + days));
return date.toISOString().slice(0, 10);
};
const buildConfig = (env = process.env) => ({
tinyApiToken: env.TINY_API_TOKEN || '',
databaseUrl: env.DATABASE_URL || '',
tinyApiBaseUrl: env.TINY_API_BASE_URL || 'https://api.tiny.com.br/api2',
tinyListEndpoint: env.TINY_LIST_ENDPOINT || 'ordens.producao.pesquisa.php',
tinyDetailEndpoint: env.TINY_DETAIL_ENDPOINT || 'ordem.producao.obter.php',
tinyHttpMethod: (env.TINY_HTTP_METHOD || 'POST').toUpperCase(),
tinyStartDateParam: env.TINY_START_DATE_PARAM || 'dataInicial',
tinyEndDateParam: env.TINY_END_DATE_PARAM || 'dataFinal',
tinyDateFormat: parseEnum(env.TINY_DATE_FORMAT, ['YYYY-MM-DD', 'DD/MM/YYYY'], 'DD/MM/YYYY', 'TINY_DATE_FORMAT'),
tinyFetchDetails: parseBoolean(env.TINY_FETCH_DETAILS, false),
tinyRequestDelayMs: parseInteger(env.TINY_REQUEST_DELAY_MS, 5000),
tinyBlockRetryMs: parseInteger(env.TINY_BLOCK_RETRY_MS, 60000),
tinyMaxRetries: parseInteger(env.TINY_MAX_RETRIES, 3),
syncMode: parseEnum(env.SYNC_MODE, ['BACKFILL', 'RECENT'], 'BACKFILL', 'SYNC_MODE').toLowerCase(),
syncTimeZone: env.SYNC_TIME_ZONE || 'America/Sao_Paulo',
recentSyncDays: parseInteger(env.RECENT_SYNC_DAYS, 2),
syncStartDate: parseDate(env.SYNC_START_DATE, 'SYNC_START_DATE'),
syncEndDate: parseDate(env.SYNC_END_DATE, 'SYNC_END_DATE'),
dryRun: parseBoolean(env.DRY_RUN, false)
});
const config = buildConfig();
const validateConfig = (nextConfig = config) => {
if (!nextConfig.tinyApiToken) {
throw new Error('TINY_API_TOKEN is required');
}
if (!nextConfig.dryRun && !nextConfig.databaseUrl) {
throw new Error('DATABASE_URL is required unless DRY_RUN=true');
}
if (!['GET', 'POST'].includes(nextConfig.tinyHttpMethod)) {
throw new Error('TINY_HTTP_METHOD must be GET or POST');
}
if (nextConfig.tinyRequestDelayMs < 0) {
throw new Error('TINY_REQUEST_DELAY_MS must be zero or greater');
}
if (nextConfig.tinyBlockRetryMs < 0) {
throw new Error('TINY_BLOCK_RETRY_MS must be zero or greater');
}
if (nextConfig.tinyMaxRetries < 0) {
throw new Error('TINY_MAX_RETRIES must be zero or greater');
}
if (nextConfig.recentSyncDays < 1) {
throw new Error('RECENT_SYNC_DAYS must be 1 or greater');
}
if (nextConfig.syncMode === 'backfill' && !nextConfig.syncStartDate) {
throw new Error('SYNC_START_DATE is required when SYNC_MODE=backfill');
}
if (nextConfig.syncStartDate && nextConfig.syncEndDate && nextConfig.syncStartDate > nextConfig.syncEndDate) {
throw new Error('SYNC_START_DATE cannot be after SYNC_END_DATE');
}
};
const resolveSyncDateRange = (nextConfig = config, now = new Date()) => {
const today = toIsoDateInTimeZone(now, nextConfig.syncTimeZone);
const endDate = nextConfig.syncEndDate || today;
if (nextConfig.syncMode === 'recent') {
const startDate = nextConfig.syncStartDate || addDays(endDate, -(nextConfig.recentSyncDays - 1));
return {
mode: nextConfig.syncMode,
startDate,
endDate,
source: nextConfig.syncStartDate ? 'explicit recent range' : `recent ${nextConfig.recentSyncDays} day window`
};
}
return {
mode: nextConfig.syncMode,
startDate: nextConfig.syncStartDate,
endDate,
source: nextConfig.syncEndDate ? 'explicit backfill range' : 'backfill through today'
};
};
const publicConfig = (nextConfig = config) => ({
tinyApiBaseUrl: nextConfig.tinyApiBaseUrl,
tinyListEndpoint: nextConfig.tinyListEndpoint,
tinyDetailEndpoint: nextConfig.tinyDetailEndpoint,
tinyHttpMethod: nextConfig.tinyHttpMethod,
tinyStartDateParam: nextConfig.tinyStartDateParam,
tinyEndDateParam: nextConfig.tinyEndDateParam,
tinyDateFormat: nextConfig.tinyDateFormat,
tinyFetchDetails: nextConfig.tinyFetchDetails,
tinyRequestDelayMs: nextConfig.tinyRequestDelayMs,
tinyBlockRetryMs: nextConfig.tinyBlockRetryMs,
tinyMaxRetries: nextConfig.tinyMaxRetries,
syncMode: nextConfig.syncMode,
syncTimeZone: nextConfig.syncTimeZone,
recentSyncDays: nextConfig.recentSyncDays,
syncStartDate: nextConfig.syncStartDate,
syncEndDate: nextConfig.syncEndDate,
dryRun: nextConfig.dryRun,
hasDatabaseUrl: Boolean(nextConfig.databaseUrl),
hasTinyApiToken: Boolean(nextConfig.tinyApiToken)
});
module.exports = {
config,
buildConfig,
validateConfig,
resolveSyncDateRange,
publicConfig
};

95
src/db.js Normal file
View File

@@ -0,0 +1,95 @@
const { Pool } = require('pg');
const UPSERT_ORDER_SQL = `
INSERT INTO production_orders (
tiny_id,
number,
status,
order_reference,
issue_date,
expected_date,
product_sku,
product_description,
quantity,
unit,
integration_status,
tiny_payload,
updated_at
)
VALUES ($1, $2, $3, $4, $5::date, $6::date, $7, $8, $9, $10, $11, $12::jsonb, CURRENT_TIMESTAMP)
ON CONFLICT (tiny_id) DO UPDATE SET
number = EXCLUDED.number,
status = EXCLUDED.status,
order_reference = EXCLUDED.order_reference,
issue_date = EXCLUDED.issue_date,
expected_date = EXCLUDED.expected_date,
product_sku = EXCLUDED.product_sku,
product_description = EXCLUDED.product_description,
quantity = EXCLUDED.quantity,
unit = EXCLUDED.unit,
integration_status = EXCLUDED.integration_status,
tiny_payload = EXCLUDED.tiny_payload,
updated_at = CURRENT_TIMESTAMP
RETURNING id;
`;
class ProductionOrderRepository {
constructor(databaseUrl) {
this.pool = new Pool({ connectionString: databaseUrl });
}
async connect() {
await this.pool.query(`SET TIME ZONE 'America/Sao_Paulo';`);
}
async close() {
await this.pool.end();
}
async upsertOrder(order, markers) {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(UPSERT_ORDER_SQL, [
order.tinyId,
order.number,
order.status,
order.orderReference,
order.issueDate,
order.expectedDate,
order.productSku,
order.productDescription,
order.quantity,
order.unit,
order.integrationStatus,
JSON.stringify(order.tinyPayload)
]);
const productionOrderId = result.rows[0].id;
await client.query('DELETE FROM production_order_markers WHERE production_order_id = $1', [productionOrderId]);
for (const marker of markers) {
await client.query(
`
INSERT INTO production_order_markers (production_order_id, label, color)
VALUES ($1, $2, $3)
ON CONFLICT (production_order_id, label) DO UPDATE SET color = EXCLUDED.color;
`,
[productionOrderId, marker.label, marker.color || null]
);
}
await client.query('COMMIT');
return productionOrderId;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}
module.exports = {
ProductionOrderRepository
};

16
src/index.js Normal file
View File

@@ -0,0 +1,16 @@
const { config, validateConfig } = require('./config');
const logger = require('./logger');
const { runSync } = require('./sync');
const main = async () => {
validateConfig();
await runSync(config);
};
main().catch((error) => {
logger.error('production-orders-sync failed', {
message: error.message,
stack: error.stack
});
process.exitCode = 1;
});

26
src/logger.js Normal file
View File

@@ -0,0 +1,26 @@
const LOG_PREFIX = '[Production Orders Sync]';
const serializeMeta = (meta) => {
if (!meta || Object.keys(meta).length === 0) return '';
return ` ${JSON.stringify(meta)}`;
};
const log = (level, message, meta = {}) => {
const timestamp = new Date().toISOString();
const output = `${timestamp} ${level.toUpperCase()} ${LOG_PREFIX} ${message}${serializeMeta(meta)}`;
if (level === 'error') {
console.error(output);
return;
}
if (level === 'warn') {
console.warn(output);
return;
}
console.log(output);
};
module.exports = {
info: (message, meta) => log('info', message, meta),
warn: (message, meta) => log('warn', message, meta),
error: (message, meta) => log('error', message, meta)
};

179
src/mapper.js Normal file
View File

@@ -0,0 +1,179 @@
const STATUS_LABELS = {
open: 'Em aberto',
in_progress: 'Em andamento',
finished: 'Finalizada',
canceled: 'Cancelada'
};
const normalizeText = (value) => String(value || '')
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
const normalizeStatus = (status) => {
const normalized = normalizeText(status).replace(/\s+/g, ' ');
if (['open', 'em_aberto', 'em aberto', 'aberta'].includes(normalized)) return 'open';
if (['in_progress', 'andamento', 'em andamento'].includes(normalized)) return 'in_progress';
if (['finished', 'finalizada', 'finalizado'].includes(normalized)) return 'finished';
if (['canceled', 'cancelada', 'cancelado', 'cancelled'].includes(normalized)) return 'canceled';
return 'open';
};
const firstDefined = (...values) => values.find((value) => value !== undefined && value !== null && value !== '');
const getPath = (source, path) => path.split('.').reduce((value, key) => {
if (value === undefined || value === null) return undefined;
return value[key];
}, source);
const pick = (source, paths) => {
for (const path of paths) {
const value = getPath(source, path);
if (value !== undefined && value !== null && value !== '') return value;
}
return undefined;
};
const parseDate = (value) => {
if (!value) return null;
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10);
const text = String(value).trim();
const isoMatch = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (isoMatch) {
const [, year, month, day] = isoMatch;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
const brMatch = text.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})/);
if (brMatch) {
const [, day, month, year] = brMatch;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
}
return null;
};
const parseNumber = (value) => {
if (value === undefined || value === null || value === '') return 0;
if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
const normalized = String(value).trim().replace(/\./g, '').replace(',', '.');
const parsed = Number.parseFloat(normalized);
return Number.isFinite(parsed) ? parsed : 0;
};
const unwrapTinyRecord = (record) => {
if (!record || typeof record !== 'object') return record;
return record.ordem_producao || record.ordemProducao || record.ordem || record.production_order || record;
};
const normalizeMarker = (marker) => {
if (!marker) return null;
if (typeof marker === 'string') {
const label = marker.trim();
return label ? { label, color: null } : null;
}
const label = firstDefined(marker.label, marker.nome, marker.name, marker.descricao, marker.description);
if (!label) return null;
return {
label: String(label).trim(),
color: firstDefined(marker.color, marker.cor, marker.colour, null)
};
};
const extractMarkers = (raw) => {
const candidates = [
pick(raw, ['marcadores', 'markers', 'tags', 'etiquetas']),
pick(raw, ['marcador', 'marker', 'tag', 'etiqueta'])
].filter(Boolean);
const markers = [];
for (const candidate of candidates) {
const list = Array.isArray(candidate) ? candidate : [candidate];
for (const entry of list) {
const normalized = normalizeMarker(
entry?.marcador || entry?.marker || entry?.tag || entry?.etiqueta || entry
);
if (normalized) markers.push(normalized);
}
}
return [...new Map(markers.map((marker) => [marker.label, marker])).values()];
};
const buildOrderReference = (raw) => {
const direct = pick(raw, [
'order_reference',
'referencia_pedido',
'pedido_venda',
'pedidoVenda',
'numero_pedido',
'numeroPedido',
'pedido.numero',
'pedido.id'
]);
if (direct) return String(direct);
const linkedOrders = pick(raw, ['pedidos', 'pedidos_venda', 'pedidosVenda']);
if (!Array.isArray(linkedOrders)) return '';
return linkedOrders
.map((item) => pick(unwrapTinyRecord(item), ['numero', 'id', 'codigo']))
.filter(Boolean)
.join(', ');
};
const mapProductionOrder = (record) => {
const raw = unwrapTinyRecord(record);
if (!raw || typeof raw !== 'object') {
return { order: null, markers: [], reason: 'record is not an object' };
}
const tinyId = pick(raw, ['id', 'id_ordem_producao', 'idOrdemProducao', 'codigo', 'tiny_id']);
const productDescription = pick(raw, [
'product_description',
'descricao_produto',
'produto_descricao',
'descricao',
'produto.descricao',
'produto.nome',
'nome_produto'
]);
if (!tinyId) {
return { order: null, markers: [], reason: 'missing Tiny production order id', raw };
}
if (!productDescription) {
return { order: null, markers: [], reason: 'missing product description', raw };
}
const status = normalizeStatus(pick(raw, ['status', 'situacao', 'situacao_descricao', 'estado']));
const order = {
tinyId: String(tinyId),
number: String(firstDefined(pick(raw, ['number', 'numero', 'codigo_ordem', 'numero_ordem']), tinyId)),
status,
statusLabel: STATUS_LABELS[status] || status,
orderReference: buildOrderReference(raw),
issueDate: parseDate(pick(raw, ['issue_date', 'data_emissao', 'dataEmissao', 'data_criacao', 'data', 'created_at'])),
expectedDate: parseDate(pick(raw, ['expected_date', 'data_prevista', 'dataPrevista', 'data_previsao', 'previsao', 'data_entrega'])),
productSku: String(firstDefined(pick(raw, ['product_sku', 'codigo_produto', 'produto_codigo', 'sku', 'produto.codigo', 'produto.sku']), '')),
productDescription: String(productDescription),
quantity: parseNumber(pick(raw, ['quantity', 'quantidade', 'qtde', 'produto.quantidade'])),
unit: String(firstDefined(pick(raw, ['unit', 'unidade', 'produto.unidade']), 'UN')),
integrationStatus: String(firstDefined(pick(raw, ['integration_status', 'situacao_integracao', 'integracao_status', 'status_integracao']), '')),
tinyPayload: raw
};
return {
order,
markers: extractMarkers(raw),
reason: null,
raw
};
};
module.exports = {
STATUS_LABELS,
normalizeStatus,
parseDate,
parseNumber,
unwrapTinyRecord,
mapProductionOrder
};

163
src/sync.js Normal file
View File

@@ -0,0 +1,163 @@
const logger = require('./logger');
const { publicConfig, resolveSyncDateRange } = require('./config');
const { mapProductionOrder } = require('./mapper');
const { TinyClient, findArrayPaths, getTopLevelShape } = require('./tinyClient');
const { ProductionOrderRepository } = require('./db');
const safeRawPreview = (raw, maxKeys = 25) => {
if (!raw || typeof raw !== 'object') return raw;
const keys = Object.keys(raw).slice(0, maxKeys);
return keys.reduce((preview, key) => {
preview[key] = raw[key];
return preview;
}, {});
};
const orderWritePreview = (order, markers) => ({
production_orders: {
tiny_id: order.tinyId,
number: order.number,
status: order.status,
order_reference: order.orderReference,
issue_date: order.issueDate,
expected_date: order.expectedDate,
product_sku: order.productSku,
product_description: order.productDescription,
quantity: order.quantity,
unit: order.unit,
integration_status: order.integrationStatus,
tiny_payload_keys: order.tinyPayload && typeof order.tinyPayload === 'object' ? Object.keys(order.tinyPayload) : []
},
production_order_markers: markers
});
const runSync = async (config) => {
const syncDateRange = resolveSyncDateRange(config);
logger.info('Start config', {
...publicConfig(config),
resolvedDateRange: syncDateRange
});
const tinyClient = new TinyClient(config);
const repository = config.dryRun ? null : new ProductionOrderRepository(config.databaseUrl);
const stats = {
pagesFetched: 0,
ordersFound: 0,
rowsMapped: 0,
rowsWouldUpsert: 0,
rowsUpserted: 0,
skippedInvalidRows: 0,
detailErrors: 0
};
if (repository) await repository.connect();
try {
let page = 1;
let totalPages = 1;
do {
const pageResult = await tinyClient.listProductionOrders(page, {
startDate: syncDateRange.startDate,
endDate: syncDateRange.endDate
});
stats.pagesFetched += 1;
totalPages = pageResult.totalPages;
logger.info('Tiny production orders page fetched', {
page,
totalPages,
dateRange: {
startDate: syncDateRange.startDate,
endDate: syncDateRange.endDate,
mode: syncDateRange.mode,
source: syncDateRange.source
},
endpoint: config.tinyListEndpoint,
ordersFound: pageResult.orders.length
});
if (config.dryRun) {
logger.info('DRY_RUN Tiny payload shape', {
page,
endpoint: config.tinyListEndpoint,
shape: getTopLevelShape(pageResult.raw),
arrayPaths: findArrayPaths(pageResult.raw)
});
logger.info('DRY_RUN extracted order preview', {
page,
extractedOrders: pageResult.orders.length,
firstOrderRawPreview: safeRawPreview(pageResult.orders[0] || null)
});
}
for (const summaryRecord of pageResult.orders) {
stats.ordersFound += 1;
let record = summaryRecord;
if (config.tinyFetchDetails) {
const mappedSummary = mapProductionOrder(summaryRecord);
const tinyId = mappedSummary.order?.tinyId;
if (tinyId) {
try {
record = await tinyClient.getProductionOrderDetail(tinyId);
} catch (error) {
stats.detailErrors += 1;
logger.warn('Tiny detail fetch failed, using list summary payload', {
tinyId,
error: error.message
});
}
}
}
const mapped = mapProductionOrder(record);
if (!mapped.order) {
stats.skippedInvalidRows += 1;
logger.warn('Skipped invalid Tiny production order row', {
reason: mapped.reason,
rawPreview: safeRawPreview(mapped.raw || record)
});
continue;
}
stats.rowsMapped += 1;
if (config.dryRun) {
stats.rowsWouldUpsert += 1;
logger.info('DRY_RUN would upsert production order', {
tinyId: mapped.order.tinyId,
number: mapped.order.number,
status: mapped.order.status,
markers: mapped.markers.length,
writePreview: orderWritePreview(mapped.order, mapped.markers),
rawPreview: safeRawPreview(mapped.raw)
});
continue;
}
await repository.upsertOrder(mapped.order, mapped.markers);
stats.rowsUpserted += 1;
}
logger.info('Page sync complete', {
page,
rowsMappedSoFar: stats.rowsMapped,
rowsWouldUpsertSoFar: stats.rowsWouldUpsert,
rowsUpsertedSoFar: stats.rowsUpserted,
skippedInvalidRowsSoFar: stats.skippedInvalidRows
});
page += 1;
} while (page <= totalPages);
logger.info('End summary', stats);
return stats;
} finally {
if (repository) await repository.close();
}
};
module.exports = {
runSync
};

222
src/tinyClient.js Normal file
View File

@@ -0,0 +1,222 @@
const logger = require('./logger');
const { unwrapTinyRecord } = require('./mapper');
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const isBlockMessage = (message) => {
const text = String(message || '').toLowerCase();
return [
'limite',
'bloque',
'rate',
'muitas requisi',
'too many',
'temporariamente indisponivel',
'temporariamente indisponível'
].some((term) => text.includes(term));
};
const joinUrl = (baseUrl, endpoint) => `${baseUrl.replace(/\/+$/, '')}/${String(endpoint).replace(/^\/+/, '')}`;
const formatTinyDate = (date, format) => {
if (!date || format === 'YYYY-MM-DD') return date;
const match = String(date).match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!match) return date;
const [, year, month, day] = match;
return `${day}/${month}/${year}`;
};
const describeValue = (value, depth = 0) => {
if (value === null) return { type: 'null' };
if (Array.isArray(value)) {
return {
type: 'array',
length: value.length,
sample: value.length > 0 && depth < 2 ? describeValue(value[0], depth + 1) : undefined
};
}
if (typeof value === 'object') {
const keys = Object.keys(value);
return {
type: 'object',
keys,
sample: depth < 2
? keys.slice(0, 10).reduce((shape, key) => {
shape[key] = describeValue(value[key], depth + 1);
return shape;
}, {})
: undefined
};
}
return { type: typeof value, sample: String(value).slice(0, 120) };
};
const getTopLevelShape = (payload) => describeValue(payload);
const findArrayPaths = (value, path = [], depth = 0) => {
if (depth > 4 || value === null || value === undefined) return [];
if (Array.isArray(value)) {
return [{
path: path.join('.') || '<root>',
length: value.length,
sampleKeys: value[0] && typeof value[0] === 'object' ? Object.keys(unwrapTinyRecord(value[0])).slice(0, 30) : []
}];
}
if (typeof value !== 'object') return [];
return Object.entries(value).flatMap(([key, child]) => findArrayPaths(child, [...path, key], depth + 1));
};
class TinyClient {
constructor(config) {
this.config = config;
this.lastRequestAt = 0;
}
async pace() {
const elapsed = Date.now() - this.lastRequestAt;
const waitMs = Math.max(this.config.tinyRequestDelayMs - elapsed, 0);
if (waitMs > 0) await sleep(waitMs);
this.lastRequestAt = Date.now();
}
async request(endpoint, params, context) {
const body = new URLSearchParams({
token: this.config.tinyApiToken,
formato: 'json',
...params
});
for (let attempt = 1; attempt <= this.config.tinyMaxRetries + 1; attempt += 1) {
await this.pace();
const method = this.config.tinyHttpMethod;
const url = new URL(joinUrl(this.config.tinyApiBaseUrl, endpoint));
const fetchOptions = { method };
if (method === 'GET') {
for (const [key, value] of body.entries()) url.searchParams.set(key, value);
} else {
fetchOptions.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
fetchOptions.body = body.toString();
}
let response;
let payload;
const requestedUrl = method === 'GET' ? url.toString().replace(this.config.tinyApiToken, '<redacted>') : url.toString();
try {
logger.info('Tiny request', {
...context,
endpoint,
method,
attempt,
url: requestedUrl,
params: Object.fromEntries([...body.entries()].map(([key, value]) => [key, key === 'token' ? '<redacted>' : value]))
});
response = await fetch(url, fetchOptions);
const text = await response.text();
payload = text ? JSON.parse(text) : {};
} catch (error) {
if (attempt > this.config.tinyMaxRetries) throw error;
logger.warn('Tiny request failed, retrying', { ...context, attempt, error: error.message });
await sleep(this.config.tinyBlockRetryMs);
continue;
}
const message = payload?.retorno?.erros?.[0]?.erro || payload?.retorno?.erro || payload?.erro || payload?.message;
if (response.status === 429 || isBlockMessage(message)) {
logger.warn('Tiny block/rate limit message', {
...context,
attempt,
statusCode: response.status,
message: message || 'HTTP 429'
});
if (attempt > this.config.tinyMaxRetries) {
throw new Error(`Tiny rate/block limit persisted: ${message || response.status}`);
}
await sleep(this.config.tinyBlockRetryMs);
continue;
}
if (!response.ok) {
throw new Error(`Tiny HTTP ${response.status}: ${message || response.statusText}`);
}
const status = payload?.retorno?.status;
if (status && String(status).toUpperCase() === 'ERRO') {
throw new Error(`Tiny API error: ${message || 'unknown error'}`);
}
return payload;
}
throw new Error('Tiny request exhausted retries');
}
extractOrders(payload) {
const retorno = payload?.retorno || payload;
const candidates = [
retorno?.ordens_producao,
retorno?.ordensProducao,
retorno?.ordens,
retorno?.production_orders,
retorno?.registros,
retorno?.itens,
Array.isArray(retorno) ? retorno : null
];
const list = candidates.find((candidate) => Array.isArray(candidate)) || [];
return list.map(unwrapTinyRecord).filter(Boolean);
}
getTotalPages(payload) {
const retorno = payload?.retorno || {};
const total = Number.parseInt(retorno.numero_paginas || retorno.total_paginas || retorno.pages || '1', 10);
return Number.isFinite(total) && total > 0 ? total : 1;
}
async listProductionOrders(page, dateRange) {
const params = { pagina: String(page) };
if (dateRange.startDate) params[this.config.tinyStartDateParam] = formatTinyDate(dateRange.startDate, this.config.tinyDateFormat);
if (dateRange.endDate) params[this.config.tinyEndDateParam] = formatTinyDate(dateRange.endDate, this.config.tinyDateFormat);
logger.info('Fetching Tiny production orders page/date', {
page,
startDate: dateRange.startDate || null,
endDate: dateRange.endDate || null,
tinyStartDate: params[this.config.tinyStartDateParam] || null,
tinyEndDate: params[this.config.tinyEndDateParam] || null,
tinyDateFormat: this.config.tinyDateFormat,
endpoint: this.config.tinyListEndpoint
});
const payload = await this.request(this.config.tinyListEndpoint, params, { operation: 'list', page });
return {
orders: this.extractOrders(payload),
totalPages: this.getTotalPages(payload),
raw: payload
};
}
async getProductionOrderDetail(tinyId) {
const payload = await this.request(
this.config.tinyDetailEndpoint,
{ id: String(tinyId) },
{ operation: 'detail', tinyId: String(tinyId) }
);
const retorno = payload?.retorno || payload;
return unwrapTinyRecord(
retorno?.ordem_producao ||
retorno?.ordemProducao ||
retorno?.ordem ||
retorno?.production_order ||
retorno
);
}
}
module.exports = {
TinyClient,
sleep,
isBlockMessage,
formatTinyDate,
getTopLevelShape,
findArrayPaths
};