548 lines
19 KiB
TypeScript
548 lines
19 KiB
TypeScript
import axios from 'axios';
|
|
import dotenv from 'dotenv';
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
dotenv.config();
|
|
|
|
type TinyProduct = {
|
|
id: string;
|
|
codigo: string;
|
|
nome: string;
|
|
unidade?: string;
|
|
};
|
|
|
|
type TinyStructureComponent = {
|
|
id_componente?: string | number;
|
|
codigo?: string | number;
|
|
nome?: string;
|
|
quantidade?: string | number;
|
|
};
|
|
|
|
type ProductStructurePayload = {
|
|
order: {
|
|
tinyId: string;
|
|
number: string;
|
|
status: string;
|
|
productSku: string;
|
|
productDescription: string;
|
|
quantity: string;
|
|
unit: string;
|
|
issueDate: string | null;
|
|
expectedDate: string | null;
|
|
supplier: string;
|
|
lotCode: string;
|
|
notes: string;
|
|
};
|
|
components: Array<{
|
|
componentTinyId: string;
|
|
componentName: string;
|
|
componentSku: string;
|
|
quantityPerUnit: string;
|
|
totalQuantity: string;
|
|
unit: string;
|
|
}>;
|
|
steps: never[];
|
|
};
|
|
|
|
type SyncState = {
|
|
status?: 'running' | 'stopped' | 'completed';
|
|
processedProducts?: number;
|
|
failedProducts?: number;
|
|
lastSku?: string;
|
|
lastFailedSku?: string;
|
|
lastMessage?: string;
|
|
updatedAt?: string;
|
|
};
|
|
|
|
type RequestedProduct = {
|
|
id: string;
|
|
sku: string;
|
|
};
|
|
|
|
const TINY_API_TOKEN = process.env.TINY_API_TOKEN;
|
|
const GRAPHS_PRODUCTION_ORDERS_URL = (
|
|
process.env.PRODUCT_STRUCTURES_GRAPHS_API_URL ||
|
|
process.env.PRODUCTION_ORDERS_GRAPHS_API_URL ||
|
|
process.env.GRAPHS_PRODUCTION_ORDERS_API_URL ||
|
|
(process.env.GRAPHS_API_BASE_URL ? `${process.env.GRAPHS_API_BASE_URL.replace(/\/+$/, '')}/api/production-orders/tiny-sync` : '') ||
|
|
'http://localhost:3004/api/production-orders/tiny-sync'
|
|
);
|
|
const GRAPHS_PRODUCTION_ORDERS_API_KEY = (
|
|
process.env.PRODUCT_STRUCTURES_GRAPHS_API_KEY ||
|
|
process.env.PRODUCTION_ORDERS_GRAPHS_API_KEY ||
|
|
process.env.GRAPHS_PRODUCTION_ORDERS_API_KEY ||
|
|
process.env.PRODUCTION_ORDERS_API_KEY ||
|
|
process.env.GRAPHS_API_KEY ||
|
|
process.env.NEXSTAR_GRAPHS_API_KEY ||
|
|
process.env.API_KEY ||
|
|
'nexstar_secret_key_123'
|
|
);
|
|
const INPUT_FILE = process.env.PRODUCT_STRUCTURES_INPUT_FILE || '';
|
|
const SKUS = String(process.env.PRODUCT_STRUCTURE_SKUS || '')
|
|
.split(',')
|
|
.map(value => value.trim())
|
|
.filter(Boolean);
|
|
const PRODUCT_IDS = String(process.env.PRODUCT_STRUCTURE_PRODUCT_IDS || '')
|
|
.split(',')
|
|
.map(value => value.trim())
|
|
.filter(Boolean);
|
|
const DRY_RUN = process.env.PRODUCT_STRUCTURES_DRY_RUN === 'true';
|
|
const FETCH_COMPONENT_UNITS = process.env.PRODUCT_STRUCTURES_FETCH_COMPONENT_UNITS !== 'false';
|
|
const STATE_FILE = process.env.PRODUCT_STRUCTURES_STATE_FILE || path.join(process.cwd(), 'product_structures_sync_state.json');
|
|
const STOP_FILE = process.env.PRODUCT_STRUCTURES_STOP_FILE || path.join(process.cwd(), 'product_structures_sync_stop.json');
|
|
|
|
const numberEnv = (name: string, fallback: number) => {
|
|
const raw = process.env[name];
|
|
if (raw === undefined || raw.trim() === '') return fallback;
|
|
|
|
const value = Number(raw);
|
|
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
};
|
|
const REQUEST_DELAY_MS = numberEnv('PRODUCT_STRUCTURES_REQUEST_DELAY_MS', 2500);
|
|
const GRAPHS_RETRY_DELAY_MS = numberEnv('PRODUCT_STRUCTURES_GRAPHS_RETRY_DELAY_MS', 30000);
|
|
const GRAPHS_MAX_RETRIES = numberEnv('PRODUCT_STRUCTURES_GRAPHS_MAX_RETRIES', 3);
|
|
const MAX_PRODUCTS = numberEnv('PRODUCT_STRUCTURES_MAX_PRODUCTS', 0);
|
|
|
|
let nextRequestAt = 0;
|
|
const productDetailCache = new Map<string, TinyProduct | null>();
|
|
|
|
class SyncStoppedError extends Error {
|
|
constructor() {
|
|
super('Product structures sync stopped by request.');
|
|
}
|
|
}
|
|
|
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
function assertNotStopped() {
|
|
if (fs.existsSync(STOP_FILE)) {
|
|
throw new SyncStoppedError();
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
async function waitForRequestSlot(reason: string) {
|
|
const waitMs = Math.max(0, nextRequestAt - Date.now());
|
|
await sleepWithStop(waitMs, reason);
|
|
nextRequestAt = Date.now() + REQUEST_DELAY_MS;
|
|
}
|
|
|
|
function saveState(state: SyncState) {
|
|
fs.writeFileSync(STATE_FILE, JSON.stringify({
|
|
...state,
|
|
updatedAt: new Date().toISOString()
|
|
}, null, 2));
|
|
}
|
|
|
|
function describeRequestError(error: any) {
|
|
if (error?.response) {
|
|
const body = JSON.stringify(error.response.data ?? '');
|
|
const truncatedBody = body.length > 500 ? `${body.slice(0, 500)}...` : body;
|
|
return `HTTP ${error.response.status}: ${truncatedBody}`;
|
|
}
|
|
|
|
return error?.message || String(error);
|
|
}
|
|
|
|
function normalizeText(value: unknown) {
|
|
return String(value ?? '').trim();
|
|
}
|
|
|
|
function normalizeNumber(value: unknown) {
|
|
if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
|
|
const raw = normalizeText(value);
|
|
if (!raw) return 0;
|
|
const normalized = raw.includes(',')
|
|
? raw.replace(/\./g, '').replace(',', '.')
|
|
: raw;
|
|
const parsed = Number(normalized);
|
|
return Number.isFinite(parsed) ? parsed : 0;
|
|
}
|
|
|
|
function formatTinyDecimal(value: number) {
|
|
return Number.isFinite(value) ? String(value) : '0';
|
|
}
|
|
|
|
function parseDelimitedLine(line: string, delimiter: string) {
|
|
const values: string[] = [];
|
|
let current = '';
|
|
let quoted = false;
|
|
|
|
for (let index = 0; index < line.length; index += 1) {
|
|
const char = line[index];
|
|
const nextChar = line[index + 1];
|
|
|
|
if (char === '"' && quoted && nextChar === '"') {
|
|
current += '"';
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (char === '"') {
|
|
quoted = !quoted;
|
|
continue;
|
|
}
|
|
|
|
if (char === delimiter && !quoted) {
|
|
values.push(current);
|
|
current = '';
|
|
continue;
|
|
}
|
|
|
|
current += char;
|
|
}
|
|
|
|
values.push(current);
|
|
return values;
|
|
}
|
|
|
|
function parseDelimitedFile(raw: string, delimiter: string) {
|
|
const lines = raw
|
|
.replace(/^\uFEFF/, '')
|
|
.split(/\r?\n/)
|
|
.filter(line => line.trim() !== '');
|
|
const headers = parseDelimitedLine(lines[0] || '', delimiter).map(header => header.trim());
|
|
|
|
return lines.slice(1).map(line => {
|
|
const values = parseDelimitedLine(line, delimiter);
|
|
return headers.reduce<Record<string, string>>((row, header, index) => {
|
|
row[header] = values[index] || '';
|
|
return row;
|
|
}, {});
|
|
});
|
|
}
|
|
|
|
function readInputFile() {
|
|
const filePath = path.resolve(INPUT_FILE);
|
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
const extension = path.extname(filePath).toLowerCase();
|
|
|
|
if (extension === '.csv') {
|
|
return parseDelimitedFile(raw, ',');
|
|
}
|
|
|
|
if (extension === '.tsv') {
|
|
return parseDelimitedFile(raw, '\t');
|
|
}
|
|
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) return parsed;
|
|
return parsed?.skus || parsed?.products || parsed?.items || [];
|
|
}
|
|
|
|
function loadRequestedProducts() {
|
|
const productsFromFile: RequestedProduct[] = INPUT_FILE
|
|
? readInputFile().map((entry: any) => ({
|
|
id: normalizeText(entry.id || entry.tinyId || entry.productId || entry['ID Produto'] || entry.ID_Produto),
|
|
sku: normalizeText(entry.sku || entry.codigo || entry.productSku || entry.SKU || entry.Código || entry.Codigo)
|
|
}))
|
|
: [];
|
|
|
|
return productsFromFile
|
|
.concat(SKUS.map(sku => ({ id: '', sku })))
|
|
.concat(PRODUCT_IDS.map(id => ({ id, sku: '' })))
|
|
.filter((entry: RequestedProduct, index: number, all: RequestedProduct[]) => {
|
|
const key = entry.id ? `id:${entry.id}` : `sku:${entry.sku.toUpperCase()}`;
|
|
return (entry.id || entry.sku) && all.findIndex((other: RequestedProduct) => {
|
|
const otherKey = other.id ? `id:${other.id}` : `sku:${other.sku.toUpperCase()}`;
|
|
return otherKey === key;
|
|
}) === index;
|
|
});
|
|
}
|
|
|
|
async function tinyPost(servicePhp: string, params: URLSearchParams) {
|
|
if (!TINY_API_TOKEN) {
|
|
throw new Error('Missing TINY_API_TOKEN.');
|
|
}
|
|
|
|
await waitForRequestSlot(`Tiny ${servicePhp} request slot`);
|
|
return axios.post(`https://api.tiny.com.br/api2/${servicePhp}`, params, {
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
});
|
|
}
|
|
|
|
async function searchProductBySku(sku: string): Promise<TinyProduct | null> {
|
|
const params = new URLSearchParams();
|
|
params.append('token', TINY_API_TOKEN!);
|
|
params.append('formato', 'JSON');
|
|
params.append('pesquisa', sku);
|
|
params.append('pagina', '1');
|
|
|
|
const response = await tinyPost('produtos.pesquisa.php', params);
|
|
const retorno = response.data?.retorno;
|
|
if (retorno?.status !== 'OK') {
|
|
console.warn(`[Tiny] Product search failed for ${sku}: ${JSON.stringify(retorno?.erros || retorno || 'Unknown error')}`);
|
|
return null;
|
|
}
|
|
|
|
const products = (retorno.produtos || [])
|
|
.map((entry: any) => entry?.produto || entry)
|
|
.filter(Boolean);
|
|
const exact = products.find((product: any) => normalizeText(product.codigo).toUpperCase() === sku.toUpperCase());
|
|
const selected = exact || products[0];
|
|
if (!selected) return null;
|
|
|
|
return {
|
|
id: normalizeText(selected.id),
|
|
codigo: normalizeText(selected.codigo),
|
|
nome: normalizeText(selected.nome),
|
|
unidade: normalizeText(selected.unidade)
|
|
};
|
|
}
|
|
|
|
async function getProductById(id: string): Promise<TinyProduct | null> {
|
|
if (productDetailCache.has(id)) return productDetailCache.get(id) || null;
|
|
|
|
const params = new URLSearchParams();
|
|
params.append('token', TINY_API_TOKEN!);
|
|
params.append('formato', 'JSON');
|
|
params.append('id', id);
|
|
|
|
const response = await tinyPost('produto.obter.php', params);
|
|
const produto = response.data?.retorno?.produto;
|
|
if (!produto) {
|
|
console.warn(`[Tiny] Product get failed for ${id}: ${JSON.stringify(response.data?.retorno?.erros || response.data?.retorno || 'Unknown error')}`);
|
|
productDetailCache.set(id, null);
|
|
return null;
|
|
}
|
|
|
|
const detail = {
|
|
id: normalizeText(produto.id || id),
|
|
codigo: normalizeText(produto.codigo),
|
|
nome: normalizeText(produto.nome),
|
|
unidade: normalizeText(produto.unidade)
|
|
};
|
|
productDetailCache.set(id, detail);
|
|
return detail;
|
|
}
|
|
|
|
async function getProductStructure(product: TinyProduct): Promise<ProductStructurePayload | null> {
|
|
const params = new URLSearchParams();
|
|
params.append('token', TINY_API_TOKEN!);
|
|
params.append('formato', 'JSON');
|
|
params.append('id', product.id);
|
|
|
|
const response = await tinyPost('produto.obter.estrutura.php', params);
|
|
const retorno = response.data?.retorno;
|
|
if (retorno?.status !== 'OK') {
|
|
console.warn(`[Tiny] Product structure failed for ${product.codigo || product.id}: ${JSON.stringify(retorno?.erros || retorno || 'Unknown error')}`);
|
|
return null;
|
|
}
|
|
|
|
const produto = retorno.produto || {};
|
|
const finishedSku = normalizeText(produto.codigo || product.codigo);
|
|
const finishedName = normalizeText(produto.nome || product.nome);
|
|
const rawComponents = (Array.isArray(produto.estrutura) ? produto.estrutura : [])
|
|
.map((entry: any) => entry?.item || entry)
|
|
.filter(Boolean);
|
|
const components = [];
|
|
|
|
for (const component of rawComponents as TinyStructureComponent[]) {
|
|
const quantityPerUnit = normalizeNumber(component.quantidade);
|
|
const componentTinyId = normalizeText(component.id_componente);
|
|
const componentDetail = FETCH_COMPONENT_UNITS && componentTinyId
|
|
? await getProductById(componentTinyId)
|
|
: null;
|
|
|
|
components.push({
|
|
componentTinyId,
|
|
componentName: normalizeText(component.nome || componentDetail?.nome),
|
|
componentSku: normalizeText(component.codigo || componentDetail?.codigo),
|
|
quantityPerUnit: formatTinyDecimal(quantityPerUnit),
|
|
totalQuantity: formatTinyDecimal(quantityPerUnit),
|
|
unit: normalizeText(componentDetail?.unidade)
|
|
});
|
|
}
|
|
|
|
const filteredComponents = components
|
|
.filter((component: ProductStructurePayload['components'][number]) => component.componentSku || component.componentName);
|
|
|
|
return {
|
|
order: {
|
|
tinyId: `STRUCTURE-${product.id}`,
|
|
number: `STRUCTURE-${finishedSku || product.id}`,
|
|
status: 'completed',
|
|
productSku: finishedSku,
|
|
productDescription: finishedName,
|
|
quantity: '1',
|
|
unit: product.unidade || 'UN',
|
|
issueDate: null,
|
|
expectedDate: null,
|
|
supplier: '',
|
|
lotCode: '',
|
|
notes: `Composição sincronizada do Tiny produto.obter.estrutura.php para produto ${product.id}.`
|
|
},
|
|
components: filteredComponents,
|
|
steps: []
|
|
};
|
|
}
|
|
|
|
async function resolveProduct(requested: RequestedProduct) {
|
|
if (requested.id) return getProductById(requested.id);
|
|
return searchProductBySku(requested.sku);
|
|
}
|
|
|
|
async function sendStructure(payload: ProductStructurePayload) {
|
|
if (DRY_RUN) {
|
|
console.log(`[dry-run] Would send structure ${payload.order.productSku}`);
|
|
console.log(JSON.stringify(payload, null, 2));
|
|
return true;
|
|
}
|
|
|
|
let attempt = 0;
|
|
while (true) {
|
|
try {
|
|
await waitForRequestSlot(`Graphs product structure ${payload.order.productSku}`);
|
|
await axios.post(GRAPHS_PRODUCTION_ORDERS_URL, payload, {
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-api-key': GRAPHS_PRODUCTION_ORDERS_API_KEY
|
|
}
|
|
});
|
|
console.log(`[Graphs Structure] Sent ${payload.order.productSku} with ${payload.components.length} component(s).`);
|
|
return true;
|
|
} catch (error: any) {
|
|
attempt += 1;
|
|
const status = error?.response?.status;
|
|
const shouldRetry = !status || status >= 500;
|
|
console.error(`[Graphs Structure] Failed to send ${payload.order.productSku} on attempt ${attempt}: ${describeRequestError(error)}`);
|
|
|
|
if (!shouldRetry || (GRAPHS_MAX_RETRIES && attempt >= GRAPHS_MAX_RETRIES)) {
|
|
console.error(`[Graphs Structure] Giving up on ${payload.order.productSku}; sync will continue.`);
|
|
return false;
|
|
}
|
|
|
|
await sleepWithStop(GRAPHS_RETRY_DELAY_MS, `retrying Graphs structure ${payload.order.productSku}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runSync() {
|
|
const requestedProducts = loadRequestedProducts();
|
|
if (!requestedProducts.length) {
|
|
throw new Error('No products configured. Set PRODUCT_STRUCTURE_SKUS, PRODUCT_STRUCTURE_PRODUCT_IDS, or PRODUCT_STRUCTURES_INPUT_FILE.');
|
|
}
|
|
|
|
console.log(`[config] Graphs URL: ${GRAPHS_PRODUCTION_ORDERS_URL}`);
|
|
console.log(`[config] Dry run: ${DRY_RUN ? 'yes' : 'no'}`);
|
|
console.log(`[config] Request pace: 1 request every ${REQUEST_DELAY_MS}ms`);
|
|
console.log(`[config] State file: ${STATE_FILE}`);
|
|
console.log(`[config] Stop file: ${STOP_FILE}`);
|
|
console.log(`[source] Found ${requestedProducts.length} requested product(s).`);
|
|
|
|
saveState({
|
|
status: 'running',
|
|
processedProducts: 0,
|
|
failedProducts: 0,
|
|
lastMessage: 'Product structures sync started'
|
|
});
|
|
|
|
let processedProducts = 0;
|
|
let failedProducts = 0;
|
|
|
|
for (const requested of requestedProducts) {
|
|
assertNotStopped();
|
|
if (MAX_PRODUCTS && processedProducts + failedProducts >= MAX_PRODUCTS) {
|
|
console.log(`[limit] Stopped after ${MAX_PRODUCTS} product(s).`);
|
|
break;
|
|
}
|
|
|
|
const label = requested.sku || requested.id;
|
|
try {
|
|
const product = await resolveProduct(requested);
|
|
if (!product?.id) {
|
|
console.warn(`[Tiny] Product not found for ${label}.`);
|
|
failedProducts += 1;
|
|
saveState({
|
|
status: 'running',
|
|
processedProducts,
|
|
failedProducts,
|
|
lastFailedSku: label,
|
|
lastMessage: `Product not found for ${label}`
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const payload = await getProductStructure(product);
|
|
if (!payload) {
|
|
failedProducts += 1;
|
|
saveState({
|
|
status: 'running',
|
|
processedProducts,
|
|
failedProducts,
|
|
lastFailedSku: product.codigo || product.id,
|
|
lastMessage: `Could not get structure for ${product.codigo || product.id}`
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const sent = await sendStructure(payload);
|
|
if (sent) {
|
|
processedProducts += 1;
|
|
} else {
|
|
failedProducts += 1;
|
|
}
|
|
|
|
saveState({
|
|
status: 'running',
|
|
processedProducts,
|
|
failedProducts,
|
|
lastSku: sent ? payload.order.productSku : undefined,
|
|
lastFailedSku: sent ? undefined : payload.order.productSku,
|
|
lastMessage: sent ? `Synced structure ${payload.order.productSku}` : `Failed structure ${payload.order.productSku}; continuing`
|
|
});
|
|
} catch (error: any) {
|
|
if (error instanceof SyncStoppedError) throw error;
|
|
failedProducts += 1;
|
|
console.error(`[sync] Failed product ${label}: ${describeRequestError(error)}`);
|
|
saveState({
|
|
status: 'running',
|
|
processedProducts,
|
|
failedProducts,
|
|
lastFailedSku: label,
|
|
lastMessage: `Failed product ${label}; continuing`
|
|
});
|
|
}
|
|
}
|
|
|
|
saveState({
|
|
status: 'completed',
|
|
processedProducts,
|
|
failedProducts,
|
|
lastMessage: failedProducts ? `Product structures sync completed with ${failedProducts} failed product(s)` : 'Product structures sync completed'
|
|
});
|
|
console.log(`Done. Synced ${processedProducts} product structure(s). Failed products: ${failedProducts}.`);
|
|
}
|
|
|
|
runSync().catch((error: any) => {
|
|
if (error instanceof SyncStoppedError) {
|
|
console.log('Product structures sync stopped by request.');
|
|
saveState({
|
|
status: 'stopped',
|
|
lastMessage: 'Product structures sync stopped by request'
|
|
});
|
|
process.exit(0);
|
|
}
|
|
|
|
console.error(`Product structures sync failed: ${describeRequestError(error)}`);
|
|
saveState({
|
|
status: 'stopped',
|
|
lastMessage: `Product structures sync failed: ${describeRequestError(error)}`
|
|
});
|
|
process.exit(1);
|
|
});
|