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
|
graphs_backfill_stop.json
|
||||||
product_structures_sync_state.json
|
product_structures_sync_state.json
|
||||||
product_structures_sync_stop.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_GRAPHS_API_KEY=${PRODUCT_STRUCTURES_GRAPHS_API_KEY}
|
||||||
- PRODUCT_STRUCTURES_REQUEST_DELAY_MS=${PRODUCT_STRUCTURES_REQUEST_DELAY_MS:-2500}
|
- PRODUCT_STRUCTURES_REQUEST_DELAY_MS=${PRODUCT_STRUCTURES_REQUEST_DELAY_MS:-2500}
|
||||||
- PRODUCT_STRUCTURES_FETCH_COMPONENT_UNITS=${PRODUCT_STRUCTURES_FETCH_COMPONENT_UNITS:-true}
|
- 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_FETCH_SELLER_DETAILS=${TINY_FETCH_SELLER_DETAILS:-false}
|
||||||
- TINY_ORDER_DETAILS_CACHE_TTL_MS=${TINY_ORDER_DETAILS_CACHE_TTL_MS:-120000}
|
- TINY_ORDER_DETAILS_CACHE_TTL_MS=${TINY_ORDER_DETAILS_CACHE_TTL_MS:-120000}
|
||||||
- TINY_LIVE_REQUEST_DELAY_MS=${TINY_LIVE_REQUEST_DELAY_MS:-6000}
|
- TINY_LIVE_REQUEST_DELAY_MS=${TINY_LIVE_REQUEST_DELAY_MS:-6000}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"backfill:graphs": "tsx src/scripts/backfill-graphs.ts",
|
"backfill:graphs": "tsx src/scripts/backfill-graphs.ts",
|
||||||
"sync:product-structures": "tsx src/scripts/sync-product-structures.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"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"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
|
// Hidden endpoint to download the stock CSV log directly from the browser
|
||||||
app.get('/api/stock-logs/download', (req: Request, res: Response) => {
|
app.get('/api/stock-logs/download', (req: Request, res: Response) => {
|
||||||
const expectedToken = process.env.TINY_WEBHOOK_SECRET;
|
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