Add Tiny Olist production order sync
All checks were successful
Build and Deploy / build-and-push (push) Successful in 49s
All checks were successful
Build and Deploy / build-and-push (push) Successful in 49s
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,3 +5,5 @@ graphs_backfill_state.json
|
||||
graphs_backfill_stop.json
|
||||
product_structures_sync_state.json
|
||||
product_structures_sync_stop.json
|
||||
production_orders_sync_state.json
|
||||
production_orders_sync_stop.json
|
||||
|
||||
@@ -27,6 +27,12 @@ services:
|
||||
- PRODUCT_STRUCTURES_GRAPHS_API_KEY=${PRODUCT_STRUCTURES_GRAPHS_API_KEY}
|
||||
- PRODUCT_STRUCTURES_REQUEST_DELAY_MS=${PRODUCT_STRUCTURES_REQUEST_DELAY_MS:-2500}
|
||||
- PRODUCT_STRUCTURES_FETCH_COMPONENT_UNITS=${PRODUCT_STRUCTURES_FETCH_COMPONENT_UNITS:-true}
|
||||
- PRODUCTION_ORDERS_TINY_ERP_COOKIE=${PRODUCTION_ORDERS_TINY_ERP_COOKIE}
|
||||
- PRODUCTION_ORDERS_GRAPHS_API_URL=${PRODUCTION_ORDERS_GRAPHS_API_URL}
|
||||
- PRODUCTION_ORDERS_GRAPHS_API_KEY=${PRODUCTION_ORDERS_GRAPHS_API_KEY}
|
||||
- PRODUCTION_ORDERS_REQUEST_DELAY_MS=${PRODUCTION_ORDERS_REQUEST_DELAY_MS:-2500}
|
||||
- PRODUCTION_ORDERS_DETAIL_BODY_TEMPLATE=${PRODUCTION_ORDERS_DETAIL_BODY_TEMPLATE:-id={{id}}}
|
||||
- PRODUCTION_ORDERS_PRODUCT_DATA_BODY_TEMPLATE=${PRODUCTION_ORDERS_PRODUCT_DATA_BODY_TEMPLATE:-idProduto={{productId}}&quantidade={{quantity}}&gerarOP=N}
|
||||
- TINY_FETCH_SELLER_DETAILS=${TINY_FETCH_SELLER_DETAILS:-false}
|
||||
- TINY_ORDER_DETAILS_CACHE_TTL_MS=${TINY_ORDER_DETAILS_CACHE_TTL_MS:-120000}
|
||||
- TINY_LIVE_REQUEST_DELAY_MS=${TINY_LIVE_REQUEST_DELAY_MS:-6000}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"start": "node dist/index.js",
|
||||
"backfill:graphs": "tsx src/scripts/backfill-graphs.ts",
|
||||
"sync:product-structures": "tsx src/scripts/sync-product-structures.ts",
|
||||
"sync:production-orders": "tsx src/scripts/sync-production-orders.ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
119
src/index.ts
119
src/index.ts
@@ -296,6 +296,125 @@ app.get('/api/stop-product-structures-sync', (req: Request, res: Response) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Hidden endpoint to sync Tiny/Olist production order details into Graphs
|
||||
app.get('/api/trigger-production-orders-sync', (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', 'sync-production-orders.js');
|
||||
const env = { ...process.env };
|
||||
const stopFile = env.PRODUCTION_ORDERS_STOP_FILE || path.join(process.cwd(), 'production_orders_sync_stop.json');
|
||||
|
||||
const optionalEnvMap: Record<string, string> = {
|
||||
orderIds: 'PRODUCTION_ORDER_IDS',
|
||||
detailUrl: 'PRODUCTION_ORDERS_DETAIL_URL',
|
||||
productDataUrl: 'PRODUCTION_ORDERS_PRODUCT_DATA_URL',
|
||||
listUrl: 'PRODUCTION_ORDERS_LIST_URL',
|
||||
listBody: 'PRODUCTION_ORDERS_LIST_BODY',
|
||||
detailBodyTemplate: 'PRODUCTION_ORDERS_DETAIL_BODY_TEMPLATE',
|
||||
productDataBodyTemplate: 'PRODUCTION_ORDERS_PRODUCT_DATA_BODY_TEMPLATE',
|
||||
graphsUrl: 'PRODUCTION_ORDERS_GRAPHS_API_URL',
|
||||
graphsApiKey: 'PRODUCTION_ORDERS_GRAPHS_API_KEY',
|
||||
dryRun: 'PRODUCTION_ORDERS_DRY_RUN',
|
||||
maxOrders: 'PRODUCTION_ORDERS_MAX_ORDERS',
|
||||
requestDelayMs: 'PRODUCTION_ORDERS_REQUEST_DELAY_MS',
|
||||
graphsRetryDelayMs: 'PRODUCTION_ORDERS_GRAPHS_RETRY_DELAY_MS',
|
||||
graphsMaxRetries: 'PRODUCTION_ORDERS_GRAPHS_MAX_RETRIES'
|
||||
};
|
||||
|
||||
for (const [queryKey, envKey] of Object.entries(optionalEnvMap)) {
|
||||
const value = req.query[queryKey];
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
env[envKey] = value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(stopFile)) {
|
||||
fs.rmSync(stopFile, { force: true });
|
||||
}
|
||||
|
||||
const child = spawn('node', [scriptPath], {
|
||||
detached: true,
|
||||
stdio: 'inherit',
|
||||
env
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(`[server]: Production orders sync process failed to start: ${error.message}`);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
console.log(`[server]: Production orders sync process exited. code=${code ?? 'null'} signal=${signal ?? 'null'}`);
|
||||
});
|
||||
|
||||
child.unref();
|
||||
|
||||
console.log('[server]: Production orders sync script manually triggered via HTTP endpoint.');
|
||||
res.status(200).json({
|
||||
status: 'STARTED',
|
||||
message: 'Production orders sync is running in the background. Check Portainer logs for progress.',
|
||||
options: {
|
||||
orderIds: env.PRODUCTION_ORDER_IDS || null,
|
||||
detailUrl: env.PRODUCTION_ORDERS_DETAIL_URL || 'https://erp.olist.com/services/ordem.producao.server/1/obterOrdemProducao',
|
||||
productDataUrl: env.PRODUCTION_ORDERS_PRODUCT_DATA_URL || 'https://erp.olist.com/services/ordem.producao.server/1/buscarDadosProduto',
|
||||
graphsUrl: env.PRODUCTION_ORDERS_GRAPHS_API_URL || env.GRAPHS_PRODUCTION_ORDERS_API_URL || deriveProductionOrdersUrl(env),
|
||||
dryRun: env.PRODUCTION_ORDERS_DRY_RUN || 'false',
|
||||
maxOrders: env.PRODUCTION_ORDERS_MAX_ORDERS || null,
|
||||
requestDelayMs: env.PRODUCTION_ORDERS_REQUEST_DELAY_MS || '2500',
|
||||
tinyErpAuthConfigured: Boolean(env.PRODUCTION_ORDERS_TINY_ERP_COOKIE || env.PRODUCTION_ORDERS_TINY_ERP_AUTHORIZATION),
|
||||
graphsApiKeyConfigured: Boolean(env.PRODUCTION_ORDERS_GRAPHS_API_KEY || env.GRAPHS_PRODUCTION_ORDERS_API_KEY || env.GRAPHS_API_KEY || env.NEXSTAR_GRAPHS_API_KEY || env.API_KEY)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/production-orders-sync-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.PRODUCTION_ORDERS_STATE_FILE || path.join(process.cwd(), 'production_orders_sync_state.json');
|
||||
const stopFile = process.env.PRODUCTION_ORDERS_STOP_FILE || path.join(process.cwd(), 'production_orders_sync_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 production orders sync state: ${error.message}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
status: state?.status || 'unknown',
|
||||
stopRequested: fs.existsSync(stopFile),
|
||||
state
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/stop-production-orders-sync', (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.PRODUCTION_ORDERS_STOP_FILE || path.join(process.cwd(), 'production_orders_sync_stop.json');
|
||||
fs.writeFileSync(stopFile, JSON.stringify({
|
||||
requestedAt: new Date().toISOString()
|
||||
}, null, 2));
|
||||
|
||||
res.status(200).json({
|
||||
status: 'STOP_REQUESTED',
|
||||
message: 'Production orders sync 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;
|
||||
|
||||
574
src/scripts/sync-production-orders.ts
Normal file
574
src/scripts/sync-production-orders.ts
Normal file
@@ -0,0 +1,574 @@
|
||||
import axios from 'axios';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
type ProductionOrderPayload = {
|
||||
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<{
|
||||
componentName: string;
|
||||
componentSku: string;
|
||||
quantityPerUnit: string;
|
||||
totalQuantity: string;
|
||||
unit: string;
|
||||
}>;
|
||||
steps: Array<{
|
||||
stepNumber: number;
|
||||
name: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type XajaxCommand = {
|
||||
cmd?: string;
|
||||
elm?: string;
|
||||
prop?: string;
|
||||
val?: string;
|
||||
src?: string;
|
||||
fn?: string;
|
||||
args?: any[];
|
||||
};
|
||||
|
||||
type SyncState = {
|
||||
status?: 'running' | 'stopped' | 'completed';
|
||||
processedOrders?: number;
|
||||
failedOrders?: number;
|
||||
lastTinyId?: string;
|
||||
lastFailedTinyId?: string;
|
||||
lastMessage?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
function deriveProductionOrdersUrl() {
|
||||
const raw = process.env.GRAPHS_API_URL || process.env.NEXSTAR_GRAPHS_API_URL || '';
|
||||
if (!raw) return '';
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
return `${parsed.origin}/api/production-orders/tiny-sync`;
|
||||
} catch {
|
||||
return raw.replace(/\/api\/data.*$/, '/api/production-orders/tiny-sync');
|
||||
}
|
||||
}
|
||||
|
||||
const TINY_ERP_BASE_URL = (process.env.PRODUCTION_ORDERS_TINY_ERP_BASE_URL || 'https://erp.olist.com').replace(/\/+$/, '');
|
||||
const TINY_ERP_COOKIE = process.env.PRODUCTION_ORDERS_TINY_ERP_COOKIE || '';
|
||||
const TINY_ERP_AUTHORIZATION = process.env.PRODUCTION_ORDERS_TINY_ERP_AUTHORIZATION || '';
|
||||
const DETAIL_URL = process.env.PRODUCTION_ORDERS_DETAIL_URL || `${TINY_ERP_BASE_URL}/services/ordem.producao.server/1/obterOrdemProducao`;
|
||||
const PRODUCT_DATA_URL = process.env.PRODUCTION_ORDERS_PRODUCT_DATA_URL || `${TINY_ERP_BASE_URL}/services/ordem.producao.server/1/buscarDadosProduto`;
|
||||
const LIST_URL = process.env.PRODUCTION_ORDERS_LIST_URL || `${TINY_ERP_BASE_URL}/services/ordem.producao.server/1/listarOrdensProducao`;
|
||||
const DETAIL_BODY_TEMPLATE = process.env.PRODUCTION_ORDERS_DETAIL_BODY_TEMPLATE || 'id={{id}}';
|
||||
const PRODUCT_DATA_BODY_TEMPLATE = process.env.PRODUCTION_ORDERS_PRODUCT_DATA_BODY_TEMPLATE || 'idProduto={{productId}}&quantidade={{quantity}}&gerarOP=N';
|
||||
const LIST_BODY = process.env.PRODUCTION_ORDERS_LIST_BODY || '';
|
||||
const GRAPHS_PRODUCTION_ORDERS_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` : '') ||
|
||||
deriveProductionOrdersUrl() ||
|
||||
'http://localhost:3004/api/production-orders/tiny-sync'
|
||||
);
|
||||
const GRAPHS_PRODUCTION_ORDERS_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 TINY_API_TOKEN = process.env.TINY_API_TOKEN || '';
|
||||
const ORDER_IDS = String(process.env.PRODUCTION_ORDER_IDS || '')
|
||||
.split(',')
|
||||
.map(id => id.trim())
|
||||
.filter(Boolean);
|
||||
const DRY_RUN = process.env.PRODUCTION_ORDERS_DRY_RUN === 'true';
|
||||
const STATE_FILE = process.env.PRODUCTION_ORDERS_STATE_FILE || path.join(process.cwd(), 'production_orders_sync_state.json');
|
||||
const STOP_FILE = process.env.PRODUCTION_ORDERS_STOP_FILE || path.join(process.cwd(), 'production_orders_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('PRODUCTION_ORDERS_REQUEST_DELAY_MS', 2500);
|
||||
const GRAPHS_RETRY_DELAY_MS = numberEnv('PRODUCTION_ORDERS_GRAPHS_RETRY_DELAY_MS', 30000);
|
||||
const GRAPHS_MAX_RETRIES = numberEnv('PRODUCTION_ORDERS_GRAPHS_MAX_RETRIES', 3);
|
||||
const MAX_ORDERS = numberEnv('PRODUCTION_ORDERS_MAX_ORDERS', 0);
|
||||
|
||||
let nextRequestAt = 0;
|
||||
const productCache = new Map<string, { sku: string; description: string; unit: string } | null>();
|
||||
|
||||
class SyncStoppedError extends Error {
|
||||
constructor() {
|
||||
super('Production orders 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 pickFirst(...values: unknown[]) {
|
||||
for (const value of values) {
|
||||
const normalized = normalizeText(value);
|
||||
if (normalized) return normalized;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function pickFirstNullable(...values: unknown[]) {
|
||||
const value = pickFirst(...values);
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function fillTemplate(template: string, values: Record<string, string>) {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => encodeURIComponent(values[key] || ''));
|
||||
}
|
||||
|
||||
function parseResponse(data: any) {
|
||||
if (typeof data === 'object' && data !== null) return data;
|
||||
|
||||
const raw = String(data ?? '').trim();
|
||||
if (!raw) return {};
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
const jsonStart = raw.indexOf('{');
|
||||
const jsonEnd = raw.lastIndexOf('}');
|
||||
if (jsonStart >= 0 && jsonEnd > jsonStart) {
|
||||
return JSON.parse(raw.slice(jsonStart, jsonEnd + 1));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Could not parse Tiny/Olist internal response as JSON.');
|
||||
}
|
||||
|
||||
function getCommands(data: any): XajaxCommand[] {
|
||||
const parsed = parseResponse(data);
|
||||
return Array.isArray(parsed?.response) ? parsed.response : [];
|
||||
}
|
||||
|
||||
function getAssignedValue(commands: XajaxCommand[], element: string) {
|
||||
const command = commands.find(entry => entry.cmd === 'as' && entry.elm === element);
|
||||
return normalizeText(command?.val);
|
||||
}
|
||||
|
||||
function getScript(commands: XajaxCommand[], pattern: RegExp) {
|
||||
return commands.find(entry => entry.cmd === 'sc' && pattern.test(String(entry.src || '')))?.src || '';
|
||||
}
|
||||
|
||||
function decodeHtml(value: string) {
|
||||
return value
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function stripHtml(value: string) {
|
||||
return decodeHtml(value.replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function parseQuantityAndUnit(value: string) {
|
||||
const normalized = stripHtml(value);
|
||||
const match = normalized.match(/^(.+?)\s+([A-Za-z]{1,5})$/);
|
||||
if (!match) return { quantity: normalized, unit: '' };
|
||||
return { quantity: match[1].trim(), unit: match[2].trim() };
|
||||
}
|
||||
|
||||
function parseCompositionHtml(html: string): ProductionOrderPayload['components'] {
|
||||
const rows = html.match(/<tr\b[^>]*>[\s\S]*?<\/tr>/gi) || [];
|
||||
|
||||
return rows.flatMap(row => {
|
||||
const cells = row.match(/<td\b[^>]*>[\s\S]*?<\/td>/gi) || [];
|
||||
if (cells.length < 4) return [];
|
||||
|
||||
const componentName = stripHtml(cells[0] || '');
|
||||
const componentSku = stripHtml(cells[1] || '');
|
||||
const perUnit = parseQuantityAndUnit(cells[2] || '');
|
||||
const total = parseQuantityAndUnit(cells[3] || '');
|
||||
|
||||
return [{
|
||||
componentName,
|
||||
componentSku,
|
||||
quantityPerUnit: perUnit.quantity,
|
||||
totalQuantity: total.quantity,
|
||||
unit: perUnit.unit || total.unit
|
||||
}];
|
||||
}).filter(component => component.componentName || component.componentSku);
|
||||
}
|
||||
|
||||
function parseSteps(commands: XajaxCommand[]): ProductionOrderPayload['steps'] {
|
||||
const script = getScript(commands, /addDetails\(/);
|
||||
const match = script.match(/addDetails\(([\s\S]*)\)/);
|
||||
if (!match) return [];
|
||||
|
||||
try {
|
||||
const steps = JSON.parse(match[1]);
|
||||
return (Array.isArray(steps) ? steps : []).map((step, index) => ({
|
||||
stepNumber: index + 1,
|
||||
name: pickFirst(step?.tarefa, step?.name, 'Etapa'),
|
||||
startDate: pickFirstNullable(step?.dataInicio),
|
||||
endDate: pickFirstNullable(step?.dataTermino),
|
||||
status: /Pendente/i.test(String(step?.situacao || '')) ? 'pendente' : pickFirst(step?.situacao, 'pendente')
|
||||
}));
|
||||
} catch (error: any) {
|
||||
console.warn(`[parse] Could not parse OP steps: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function parseStatus(commands: XajaxCommand[]) {
|
||||
const html = getAssignedValue(commands, 'view-info-ordem-producao');
|
||||
if (!html) return '';
|
||||
return stripHtml(html).replace(/^[-\s]+/, '');
|
||||
}
|
||||
|
||||
function parseNotesMeta(notes: string) {
|
||||
return {
|
||||
supplier: notes.match(/FONE?CEDOR\s*:\s*([^\n]+)/i)?.[1]?.trim() || '',
|
||||
lotCode: notes.match(/LOTE\s*:\s*([^\n]+)/i)?.[1]?.trim() || ''
|
||||
};
|
||||
}
|
||||
|
||||
function parseBuscarDadosProdutoCall(commands: XajaxCommand[]) {
|
||||
const script = getScript(commands, /buscarDadosProduto\(/);
|
||||
const match = script.match(/buscarDadosProduto\(([^,]+),\s*'([^']*)',\s*'([^']*)'\)/);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
productId: normalizeText(match[1]),
|
||||
quantity: normalizeText(match[2]),
|
||||
flag: normalizeText(match[3])
|
||||
};
|
||||
}
|
||||
|
||||
async function tinyErpPost(url: string, body: string, reason: string) {
|
||||
await waitForRequestSlot(reason);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json, text/javascript, */*; q=0.01',
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
Origin: TINY_ERP_BASE_URL,
|
||||
Referer: `${TINY_ERP_BASE_URL}/ordens_producao`,
|
||||
'X-Custom-Request-For': 'XAJAX',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-User-Agent': 'Mozilla/5.0'
|
||||
};
|
||||
|
||||
if (TINY_ERP_COOKIE) headers.Cookie = TINY_ERP_COOKIE;
|
||||
if (TINY_ERP_AUTHORIZATION) headers.Authorization = TINY_ERP_AUTHORIZATION;
|
||||
|
||||
return axios.post(url, body, { headers });
|
||||
}
|
||||
|
||||
async function fetchTinyProduct(productId: string) {
|
||||
if (!TINY_API_TOKEN || !productId) return null;
|
||||
if (productCache.has(productId)) return productCache.get(productId) || null;
|
||||
|
||||
await waitForRequestSlot(`Tiny produto.obter.php ${productId}`);
|
||||
const params = new URLSearchParams();
|
||||
params.append('token', TINY_API_TOKEN);
|
||||
params.append('formato', 'JSON');
|
||||
params.append('id', productId);
|
||||
|
||||
try {
|
||||
const response = await axios.post('https://api.tiny.com.br/api2/produto.obter.php', params, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
const produto = response.data?.retorno?.produto;
|
||||
const normalized = produto ? {
|
||||
sku: normalizeText(produto.codigo),
|
||||
description: normalizeText(produto.nome),
|
||||
unit: normalizeText(produto.unidade)
|
||||
} : null;
|
||||
productCache.set(productId, normalized);
|
||||
return normalized;
|
||||
} catch (error: any) {
|
||||
console.warn(`[Tiny] Could not fetch product ${productId}: ${describeRequestError(error)}`);
|
||||
productCache.set(productId, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOpDetail(orderId: string) {
|
||||
const body = fillTemplate(DETAIL_BODY_TEMPLATE, { id: orderId });
|
||||
const response = await tinyErpPost(DETAIL_URL, body, `Tiny/Olist OP detail ${orderId}`);
|
||||
return getCommands(response.data);
|
||||
}
|
||||
|
||||
async function fetchComposition(productId: string, quantity: string, flag: string) {
|
||||
const body = fillTemplate(PRODUCT_DATA_BODY_TEMPLATE, { productId, quantity, flag });
|
||||
const response = await tinyErpPost(PRODUCT_DATA_URL, body, `Tiny/Olist product composition ${productId}`);
|
||||
const commands = getCommands(response.data);
|
||||
const compositionHtml = getAssignedValue(commands, 'div-composicao');
|
||||
return parseCompositionHtml(compositionHtml);
|
||||
}
|
||||
|
||||
function normalizeDetail(
|
||||
orderId: string,
|
||||
commands: XajaxCommand[],
|
||||
components: ProductionOrderPayload['components'],
|
||||
finishedProduct: { sku: string; description: string; unit: string } | null
|
||||
): ProductionOrderPayload | null {
|
||||
const notes = getAssignedValue(commands, 'observacoes');
|
||||
const notesMeta = parseNotesMeta(notes);
|
||||
const productData = parseBuscarDadosProdutoCall(commands);
|
||||
const productId = getAssignedValue(commands, 'idProduto');
|
||||
const productSku = getAssignedValue(commands, 'codigo') || getAssignedValue(commands, 'sku') || finishedProduct?.sku || productId;
|
||||
|
||||
const payload: ProductionOrderPayload = {
|
||||
order: {
|
||||
tinyId: getAssignedValue(commands, 'id') || orderId,
|
||||
number: getAssignedValue(commands, 'numero') || orderId,
|
||||
status: parseStatus(commands) || getAssignedValue(commands, 'situacao'),
|
||||
productSku,
|
||||
productDescription: getAssignedValue(commands, 'produto') || finishedProduct?.description || '',
|
||||
quantity: getAssignedValue(commands, 'quantidade') || productData?.quantity || '',
|
||||
unit: stripHtml(getAssignedValue(commands, 'un-produto')) || finishedProduct?.unit || 'UN',
|
||||
issueDate: pickFirstNullable(getAssignedValue(commands, 'dataInicio')),
|
||||
expectedDate: pickFirstNullable(getAssignedValue(commands, 'dataPrevista')),
|
||||
supplier: notesMeta.supplier,
|
||||
lotCode: notesMeta.lotCode,
|
||||
notes
|
||||
},
|
||||
components,
|
||||
steps: parseSteps(commands)
|
||||
};
|
||||
|
||||
if (!payload.order.tinyId || !payload.order.productDescription || !payload.order.quantity || !payload.components.length) {
|
||||
console.warn(`[normalize] OP ${orderId} missing required data. tinyId=${payload.order.tinyId || 'none'} product=${payload.order.productDescription || 'none'} quantity=${payload.order.quantity || 'none'} components=${payload.components.length}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function loadOrderIdsFromList() {
|
||||
if (!LIST_BODY) return [];
|
||||
|
||||
const response = await tinyErpPost(LIST_URL, LIST_BODY, 'Tiny/Olist OP list');
|
||||
const commands = getCommands(response.data);
|
||||
const serialized = JSON.stringify(commands);
|
||||
return Array.from(new Set(Array.from(serialized.matchAll(/(?:idOrdemProducao|id)["':\\]+(\d{6,})/g)).map(match => match[1])));
|
||||
}
|
||||
|
||||
async function loadOrderIds() {
|
||||
if (ORDER_IDS.length) return ORDER_IDS;
|
||||
const listedIds = await loadOrderIdsFromList();
|
||||
if (listedIds.length) return listedIds;
|
||||
throw new Error('No OP source configured. Set PRODUCTION_ORDER_IDS first. Later we can use PRODUCTION_ORDERS_LIST_BODY once the listarOrdensProducao Form Data is captured.');
|
||||
}
|
||||
|
||||
async function sendProductionOrder(payload: ProductionOrderPayload) {
|
||||
if (DRY_RUN) {
|
||||
console.log(`[dry-run] Would send OP ${payload.order.tinyId || payload.order.number}`);
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
return true;
|
||||
}
|
||||
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
await waitForRequestSlot(`Graphs production order ${payload.order.tinyId || payload.order.number}`);
|
||||
await axios.post(GRAPHS_PRODUCTION_ORDERS_URL, payload, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': GRAPHS_PRODUCTION_ORDERS_API_KEY
|
||||
}
|
||||
});
|
||||
console.log(`[Graphs OP] Sent OP ${payload.order.tinyId || payload.order.number} 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 OP] Failed to send OP ${payload.order.tinyId || payload.order.number} on attempt ${attempt}: ${describeRequestError(error)}`);
|
||||
|
||||
if (!shouldRetry || (GRAPHS_MAX_RETRIES && attempt >= GRAPHS_MAX_RETRIES)) {
|
||||
console.error(`[Graphs OP] Giving up on OP ${payload.order.tinyId || payload.order.number}; sync will continue.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await sleepWithStop(GRAPHS_RETRY_DELAY_MS, `retrying Graphs OP ${payload.order.tinyId || payload.order.number}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runSync() {
|
||||
if (!TINY_ERP_COOKIE && !TINY_ERP_AUTHORIZATION) {
|
||||
throw new Error('Missing Tiny/Olist browser auth. Set PRODUCTION_ORDERS_TINY_ERP_COOKIE from the browser session cookie.');
|
||||
}
|
||||
|
||||
console.log(`[config] Detail URL: ${DETAIL_URL}`);
|
||||
console.log(`[config] Product data URL: ${PRODUCT_DATA_URL}`);
|
||||
console.log(`[config] Graphs OP 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}`);
|
||||
|
||||
saveState({
|
||||
status: 'running',
|
||||
processedOrders: 0,
|
||||
failedOrders: 0,
|
||||
lastMessage: 'Production orders sync started'
|
||||
});
|
||||
|
||||
const orderIds = await loadOrderIds();
|
||||
console.log(`[source] Found ${orderIds.length} OP id(s).`);
|
||||
|
||||
let processedOrders = 0;
|
||||
let failedOrders = 0;
|
||||
|
||||
for (const orderId of orderIds) {
|
||||
assertNotStopped();
|
||||
if (MAX_ORDERS && processedOrders + failedOrders >= MAX_ORDERS) {
|
||||
console.log(`[limit] Stopped after ${MAX_ORDERS} OP(s).`);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const detailCommands = await fetchOpDetail(orderId);
|
||||
const productData = parseBuscarDadosProdutoCall(detailCommands);
|
||||
if (!productData?.productId) {
|
||||
throw new Error(`Could not find buscarDadosProduto call for OP ${orderId}.`);
|
||||
}
|
||||
|
||||
const components = await fetchComposition(productData.productId, productData.quantity, productData.flag);
|
||||
const finishedProduct = await fetchTinyProduct(productData.productId);
|
||||
const payload = normalizeDetail(orderId, detailCommands, components, finishedProduct);
|
||||
if (!payload) {
|
||||
failedOrders += 1;
|
||||
saveState({
|
||||
status: 'running',
|
||||
processedOrders,
|
||||
failedOrders,
|
||||
lastFailedTinyId: orderId,
|
||||
lastMessage: `Could not normalize OP ${orderId}`
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const sent = await sendProductionOrder(payload);
|
||||
if (sent) {
|
||||
processedOrders += 1;
|
||||
} else {
|
||||
failedOrders += 1;
|
||||
}
|
||||
|
||||
saveState({
|
||||
status: 'running',
|
||||
processedOrders,
|
||||
failedOrders,
|
||||
lastTinyId: sent ? payload.order.tinyId : undefined,
|
||||
lastFailedTinyId: sent ? undefined : payload.order.tinyId,
|
||||
lastMessage: sent ? `Synced OP ${payload.order.tinyId}` : `Failed OP ${payload.order.tinyId}; continuing`
|
||||
});
|
||||
} catch (error: any) {
|
||||
failedOrders += 1;
|
||||
console.error(`[Tiny/Olist OP] Failed OP ${orderId}: ${describeRequestError(error)}`);
|
||||
saveState({
|
||||
status: 'running',
|
||||
processedOrders,
|
||||
failedOrders,
|
||||
lastFailedTinyId: orderId,
|
||||
lastMessage: `Failed OP ${orderId}; continuing`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
saveState({
|
||||
status: 'completed',
|
||||
processedOrders,
|
||||
failedOrders,
|
||||
lastMessage: failedOrders ? `Production orders sync completed with ${failedOrders} failed OP(s)` : 'Production orders sync completed'
|
||||
});
|
||||
console.log(`Done. Synced ${processedOrders} OP(s). Failed OPs: ${failedOrders}.`);
|
||||
}
|
||||
|
||||
runSync().catch((error: any) => {
|
||||
if (error instanceof SyncStoppedError) {
|
||||
console.log('Production orders sync stopped by request.');
|
||||
saveState({
|
||||
status: 'stopped',
|
||||
lastMessage: 'Production orders sync stopped by request'
|
||||
});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`Production orders sync failed: ${describeRequestError(error)}`);
|
||||
saveState({
|
||||
status: 'stopped',
|
||||
lastMessage: `Production orders sync failed: ${describeRequestError(error)}`
|
||||
});
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user