Compare commits
2 Commits
4547642411
...
06b59d8d37
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06b59d8d37 | ||
|
|
b1067325b9 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,5 +5,8 @@ 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
|
||||||
|
product_structures_v3_sync_state.json
|
||||||
|
product_structures_v3_sync_stop.json
|
||||||
|
data-runtime/
|
||||||
production_orders_sync_state.json
|
production_orders_sync_state.json
|
||||||
production_orders_sync_stop.json
|
production_orders_sync_stop.json
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ 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}
|
||||||
|
- OLIST_V3_CLIENT_ID=${OLIST_V3_CLIENT_ID}
|
||||||
|
- OLIST_V3_CLIENT_SECRET=${OLIST_V3_CLIENT_SECRET}
|
||||||
|
- OLIST_V3_REDIRECT_URI=${OLIST_V3_REDIRECT_URI}
|
||||||
|
- OLIST_V3_TOKEN_FILE=${OLIST_V3_TOKEN_FILE:-/app/data-runtime/olist_v3_tokens.json}
|
||||||
|
- OLIST_V3_OAUTH_STATE_FILE=${OLIST_V3_OAUTH_STATE_FILE:-/app/data-runtime/olist_v3_oauth_state.json}
|
||||||
|
- PRODUCT_STRUCTURES_V3_GRAPHS_API_URL=${PRODUCT_STRUCTURES_V3_GRAPHS_API_URL}
|
||||||
|
- PRODUCT_STRUCTURES_V3_GRAPHS_API_KEY=${PRODUCT_STRUCTURES_V3_GRAPHS_API_KEY}
|
||||||
|
- PRODUCT_STRUCTURES_V3_REQUEST_DELAY_MS=${PRODUCT_STRUCTURES_V3_REQUEST_DELAY_MS:-1000}
|
||||||
- PRODUCTION_ORDERS_TINY_ERP_COOKIE=${PRODUCTION_ORDERS_TINY_ERP_COOKIE}
|
- PRODUCTION_ORDERS_TINY_ERP_COOKIE=${PRODUCTION_ORDERS_TINY_ERP_COOKIE}
|
||||||
- PRODUCTION_ORDERS_GRAPHS_API_URL=${PRODUCTION_ORDERS_GRAPHS_API_URL}
|
- PRODUCTION_ORDERS_GRAPHS_API_URL=${PRODUCTION_ORDERS_GRAPHS_API_URL}
|
||||||
- PRODUCTION_ORDERS_GRAPHS_API_KEY=${PRODUCTION_ORDERS_GRAPHS_API_KEY}
|
- PRODUCTION_ORDERS_GRAPHS_API_KEY=${PRODUCTION_ORDERS_GRAPHS_API_KEY}
|
||||||
@@ -40,7 +48,12 @@ services:
|
|||||||
# Optional: If you use a shared Docker network for Nginx Proxy Manager, you can attach it here:
|
# Optional: If you use a shared Docker network for Nginx Proxy Manager, you can attach it here:
|
||||||
# networks:
|
# networks:
|
||||||
# - nginx-proxy-manager-network
|
# - nginx-proxy-manager-network
|
||||||
|
volumes:
|
||||||
|
- middleware_data:/app/data-runtime
|
||||||
|
|
||||||
# networks:
|
# networks:
|
||||||
# nginx-proxy-manager-network:
|
# nginx-proxy-manager-network:
|
||||||
# external: true
|
# external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
middleware_data:
|
||||||
|
|||||||
@@ -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:product-structures:v3": "tsx src/scripts/sync-product-structures-v3.ts",
|
||||||
"sync:production-orders": "tsx src/scripts/sync-production-orders.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"
|
||||||
},
|
},
|
||||||
|
|||||||
162
src/index.ts
162
src/index.ts
@@ -4,6 +4,12 @@ import webhookRoutes from './routes/webhook.route';
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import {
|
||||||
|
exchangeOlistV3AuthorizationCode,
|
||||||
|
getOlistV3AuthorizationUrl,
|
||||||
|
getOlistV3AuthStatus,
|
||||||
|
isOlistV3Configured
|
||||||
|
} from './services/olist-v3.service';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -31,6 +37,56 @@ app.get('/health', (req: Request, res: Response) => {
|
|||||||
res.status(200).json({ status: 'OK', message: 'Tiny-n8n middleware is running.' });
|
res.status(200).json({ status: 'OK', message: 'Tiny-n8n middleware is running.' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Starts the official Olist V3 OAuth flow. The callback validates a one-time state value.
|
||||||
|
app.get('/api/olist-v3/authorize', (req: Request, res: Response) => {
|
||||||
|
const expectedToken = process.env.TINY_WEBHOOK_SECRET;
|
||||||
|
if (expectedToken && req.query.token !== expectedToken) {
|
||||||
|
res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOlistV3Configured()) {
|
||||||
|
res.status(400).json({ error: 'Missing OLIST_V3_CLIENT_ID, OLIST_V3_CLIENT_SECRET, or OLIST_V3_REDIRECT_URI.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.redirect(getOlistV3AuthorizationUrl());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/olist-v3/callback', async (req: Request, res: Response) => {
|
||||||
|
const providerError = typeof req.query.error === 'string' ? req.query.error : '';
|
||||||
|
const code = typeof req.query.code === 'string' ? req.query.code : '';
|
||||||
|
const state = typeof req.query.state === 'string' ? req.query.state : '';
|
||||||
|
|
||||||
|
if (providerError) {
|
||||||
|
res.status(400).send('Olist V3 authorization was denied or failed. Return to the middleware and start authorization again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code || !state) {
|
||||||
|
res.status(400).send('Missing OAuth code or state. Start authorization again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exchangeOlistV3AuthorizationCode(code, state);
|
||||||
|
res.status(200).send('Olist V3 authorized successfully. You can close this page and trigger the V3 product structure test.');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[server]: Olist V3 OAuth callback failed: ${error.message}`);
|
||||||
|
res.status(400).send('Olist V3 authorization could not be completed. Check Portainer logs and start authorization again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/olist-v3/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;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(getOlistV3AuthStatus());
|
||||||
|
});
|
||||||
|
|
||||||
// Hidden endpoint to trigger the backfill script without terminal access
|
// Hidden endpoint to trigger the backfill script without terminal access
|
||||||
app.get('/api/trigger-backfill', (req: Request, res: Response) => {
|
app.get('/api/trigger-backfill', (req: Request, res: Response) => {
|
||||||
const expectedToken = process.env.TINY_WEBHOOK_SECRET;
|
const expectedToken = process.env.TINY_WEBHOOK_SECRET;
|
||||||
@@ -296,6 +352,112 @@ app.get('/api/stop-product-structures-sync', (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hidden endpoint to sync manufactured-product compositions from the official Olist V3 API.
|
||||||
|
app.get('/api/trigger-product-structures-v3-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-product-structures-v3.js');
|
||||||
|
const env = { ...process.env };
|
||||||
|
const stopFile = env.PRODUCT_STRUCTURES_V3_STOP_FILE || path.join(process.cwd(), 'data-runtime', 'product_structures_v3_sync_stop.json');
|
||||||
|
const optionalEnvMap: Record<string, string> = {
|
||||||
|
productIds: 'PRODUCT_STRUCTURES_V3_PRODUCT_IDS',
|
||||||
|
inputFile: 'PRODUCT_STRUCTURES_V3_INPUT_FILE',
|
||||||
|
graphsUrl: 'PRODUCT_STRUCTURES_V3_GRAPHS_API_URL',
|
||||||
|
graphsApiKey: 'PRODUCT_STRUCTURES_V3_GRAPHS_API_KEY',
|
||||||
|
dryRun: 'PRODUCT_STRUCTURES_V3_DRY_RUN',
|
||||||
|
maxProducts: 'PRODUCT_STRUCTURES_V3_MAX_PRODUCTS',
|
||||||
|
requestDelayMs: 'PRODUCT_STRUCTURES_V3_REQUEST_DELAY_MS',
|
||||||
|
graphsRetryDelayMs: 'PRODUCT_STRUCTURES_V3_GRAPHS_RETRY_DELAY_MS',
|
||||||
|
graphsMaxRetries: 'PRODUCT_STRUCTURES_V3_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 (!env.OLIST_V3_CLIENT_ID || !env.OLIST_V3_CLIENT_SECRET || !env.OLIST_V3_REDIRECT_URI) {
|
||||||
|
res.status(400).json({ error: 'Olist V3 is not configured. Set the client ID, client secret, and redirect URI first.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!getOlistV3AuthStatus().authorized) {
|
||||||
|
res.status(409).json({ error: 'Olist V3 is not authorized. Open /api/olist-v3/authorize first.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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]: Olist V3 product structures sync failed to start: ${error.message}`);
|
||||||
|
});
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
console.log(`[server]: Olist V3 product structures sync exited. code=${code ?? 'null'} signal=${signal ?? 'null'}`);
|
||||||
|
});
|
||||||
|
child.unref();
|
||||||
|
|
||||||
|
console.log('[server]: Olist V3 product structures sync manually triggered via HTTP endpoint.');
|
||||||
|
res.status(200).json({
|
||||||
|
status: 'STARTED',
|
||||||
|
message: 'Olist V3 product structures sync is running in the background. Check Portainer logs for progress.',
|
||||||
|
options: {
|
||||||
|
productIds: env.PRODUCT_STRUCTURES_V3_PRODUCT_IDS || null,
|
||||||
|
inputFile: env.PRODUCT_STRUCTURES_V3_INPUT_FILE || (env.PRODUCT_STRUCTURES_V3_PRODUCT_IDS ? null : '/app/data/product-structure-products.csv'),
|
||||||
|
graphsUrl: env.PRODUCT_STRUCTURES_V3_GRAPHS_API_URL || deriveProductionOrdersUrl(env),
|
||||||
|
dryRun: env.PRODUCT_STRUCTURES_V3_DRY_RUN || 'false',
|
||||||
|
maxProducts: env.PRODUCT_STRUCTURES_V3_MAX_PRODUCTS || null,
|
||||||
|
requestDelayMs: env.PRODUCT_STRUCTURES_V3_REQUEST_DELAY_MS || '1000',
|
||||||
|
olistV3Configured: true,
|
||||||
|
olistV3Authorized: getOlistV3AuthStatus().authorized,
|
||||||
|
graphsApiKeyConfigured: Boolean(env.PRODUCT_STRUCTURES_V3_GRAPHS_API_KEY || env.GRAPHS_API_KEY || env.NEXSTAR_GRAPHS_API_KEY)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/product-structures-v3-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.PRODUCT_STRUCTURES_V3_STATE_FILE || path.join(process.cwd(), 'data-runtime', 'product_structures_v3_sync_state.json');
|
||||||
|
const stopFile = process.env.PRODUCT_STRUCTURES_V3_STOP_FILE || path.join(process.cwd(), 'data-runtime', 'product_structures_v3_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 Olist V3 product structures state: ${error.message}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ status: state?.status || 'unknown', stopRequested: fs.existsSync(stopFile), state });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/stop-product-structures-v3-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.PRODUCT_STRUCTURES_V3_STOP_FILE || path.join(process.cwd(), 'data-runtime', 'product_structures_v3_sync_stop.json');
|
||||||
|
fs.mkdirSync(path.dirname(stopFile), { recursive: true });
|
||||||
|
fs.writeFileSync(stopFile, JSON.stringify({ requestedAt: new Date().toISOString() }, null, 2));
|
||||||
|
res.status(200).json({ status: 'STOP_REQUESTED', message: 'Olist V3 product structures sync will stop after the current request.' });
|
||||||
|
});
|
||||||
|
|
||||||
// Hidden endpoint to sync Tiny/Olist production order details into Graphs
|
// Hidden endpoint to sync Tiny/Olist production order details into Graphs
|
||||||
app.get('/api/trigger-production-orders-sync', (req: Request, res: Response) => {
|
app.get('/api/trigger-production-orders-sync', (req: Request, res: Response) => {
|
||||||
const expectedToken = process.env.TINY_WEBHOOK_SECRET;
|
const expectedToken = process.env.TINY_WEBHOOK_SECRET;
|
||||||
|
|||||||
387
src/scripts/sync-product-structures-v3.ts
Normal file
387
src/scripts/sync-product-structures-v3.ts
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { getOlistV3AccessToken, isOlistV3Configured } from '../services/olist-v3.service';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
type RequestedProduct = { id: string };
|
||||||
|
type V3Product = { id: string; sku: string; descricao: string; unidade: string };
|
||||||
|
type V3StructureItem = {
|
||||||
|
produto?: { id?: number | string; sku?: string; descricao?: string };
|
||||||
|
quantidade?: number | string;
|
||||||
|
};
|
||||||
|
type V3FabricatedProduct = { produtos?: V3StructureItem[]; etapas?: string[] };
|
||||||
|
type StructurePayload = {
|
||||||
|
order: {
|
||||||
|
tinyId: string;
|
||||||
|
number: string;
|
||||||
|
status: string;
|
||||||
|
productSku: string;
|
||||||
|
productDescription: string;
|
||||||
|
quantity: string;
|
||||||
|
unit: string;
|
||||||
|
issueDate: null;
|
||||||
|
expectedDate: 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';
|
||||||
|
sourceSignature?: string;
|
||||||
|
totalProducts?: number;
|
||||||
|
nextIndex?: number;
|
||||||
|
processedProducts?: number;
|
||||||
|
skippedProducts?: number;
|
||||||
|
failedProducts?: number;
|
||||||
|
lastProductId?: string;
|
||||||
|
lastFailedProductId?: string;
|
||||||
|
lastMessage?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const V3_API_BASE_URL = (process.env.OLIST_V3_API_BASE_URL || 'https://api.tiny.com.br/public-api/v3').replace(/\/+$/, '');
|
||||||
|
const DEFAULT_INPUT_FILE = path.join(process.cwd(), 'data', 'product-structure-products.csv');
|
||||||
|
const INPUT_FILE = process.env.PRODUCT_STRUCTURES_V3_INPUT_FILE || DEFAULT_INPUT_FILE;
|
||||||
|
const PRODUCT_IDS = String(process.env.PRODUCT_STRUCTURES_V3_PRODUCT_IDS || '')
|
||||||
|
.split(',')
|
||||||
|
.map(value => value.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const DRY_RUN = process.env.PRODUCT_STRUCTURES_V3_DRY_RUN === 'true';
|
||||||
|
const RESUME = process.env.PRODUCT_STRUCTURES_V3_RESUME !== 'false';
|
||||||
|
const RUNTIME_DIRECTORY = path.join(process.cwd(), 'data-runtime');
|
||||||
|
const STATE_FILE = process.env.PRODUCT_STRUCTURES_V3_STATE_FILE || path.join(RUNTIME_DIRECTORY, 'product_structures_v3_sync_state.json');
|
||||||
|
const STOP_FILE = process.env.PRODUCT_STRUCTURES_V3_STOP_FILE || path.join(RUNTIME_DIRECTORY, 'product_structures_v3_sync_stop.json');
|
||||||
|
|
||||||
|
function deriveGraphsUrl() {
|
||||||
|
const raw = process.env.PRODUCT_STRUCTURES_V3_GRAPHS_API_URL
|
||||||
|
|| process.env.PRODUCT_STRUCTURES_GRAPHS_API_URL
|
||||||
|
|| process.env.PRODUCTION_ORDERS_GRAPHS_API_URL
|
||||||
|
|| process.env.GRAPHS_API_URL
|
||||||
|
|| '';
|
||||||
|
if (!raw) return 'http://localhost:3004/api/production-orders/tiny-sync';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
return parsed.pathname.includes('/api/data')
|
||||||
|
? `${parsed.origin}/api/production-orders/tiny-sync`
|
||||||
|
: raw;
|
||||||
|
} catch {
|
||||||
|
return raw.replace(/\/api\/data.*$/, '/api/production-orders/tiny-sync');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GRAPHS_URL = deriveGraphsUrl();
|
||||||
|
const GRAPHS_API_KEY = process.env.PRODUCT_STRUCTURES_V3_GRAPHS_API_KEY
|
||||||
|
|| process.env.PRODUCT_STRUCTURES_GRAPHS_API_KEY
|
||||||
|
|| process.env.PRODUCTION_ORDERS_GRAPHS_API_KEY
|
||||||
|
|| process.env.GRAPHS_API_KEY
|
||||||
|
|| process.env.NEXSTAR_GRAPHS_API_KEY
|
||||||
|
|| process.env.API_KEY
|
||||||
|
|| 'nexstar_secret_key_123';
|
||||||
|
|
||||||
|
function numberEnv(name: string, fallback: number) {
|
||||||
|
const value = Number(process.env[name]);
|
||||||
|
return Number.isFinite(value) && value >= 0 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUEST_DELAY_MS = numberEnv('PRODUCT_STRUCTURES_V3_REQUEST_DELAY_MS', 1000);
|
||||||
|
const MAX_PRODUCTS = numberEnv('PRODUCT_STRUCTURES_V3_MAX_PRODUCTS', 0);
|
||||||
|
const GRAPHS_MAX_RETRIES = numberEnv('PRODUCT_STRUCTURES_V3_GRAPHS_MAX_RETRIES', 3);
|
||||||
|
const GRAPHS_RETRY_DELAY_MS = numberEnv('PRODUCT_STRUCTURES_V3_GRAPHS_RETRY_DELAY_MS', 30000);
|
||||||
|
|
||||||
|
let nextRequestAt = 0;
|
||||||
|
let latestState: SyncState | null = null;
|
||||||
|
const componentProductCache = new Map<string, V3Product | null>();
|
||||||
|
|
||||||
|
class SyncStoppedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('V3 product structure sync stopped by request.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
const text = (value: unknown) => String(value ?? '').trim();
|
||||||
|
|
||||||
|
function readProductsFromCsv(filePath: string): RequestedProduct[] {
|
||||||
|
const rows = fs.readFileSync(filePath, 'utf-8').replace(/^\uFEFF/, '').split(/\r?\n/).filter(Boolean);
|
||||||
|
const headers = rows.shift()?.split(',').map(header => header.trim().toLowerCase()) || [];
|
||||||
|
const idIndex = headers.findIndex(header => ['id', 'productid', 'tinyid', 'id produto'].includes(header));
|
||||||
|
if (idIndex < 0) throw new Error(`No product id column found in ${filePath}.`);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map(row => row.split(',')[idIndex]?.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(id => ({ id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRequestedProducts() {
|
||||||
|
const fromFile = PRODUCT_IDS.length ? [] : readProductsFromCsv(INPUT_FILE);
|
||||||
|
const requested = fromFile.concat(PRODUCT_IDS.map(id => ({ id })));
|
||||||
|
return requested.filter((item, index, all) => all.findIndex(other => other.id === item.id) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveState(state: SyncState) {
|
||||||
|
latestState = { ...state, updatedAt: new Date().toISOString() };
|
||||||
|
fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
|
||||||
|
fs.writeFileSync(STATE_FILE, JSON.stringify(latestState, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readState() {
|
||||||
|
if (!fs.existsSync(STATE_FILE)) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as SyncState;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn(`[state] Could not read previous state: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertNotStopped() {
|
||||||
|
if (fs.existsSync(STOP_FILE)) throw new SyncStoppedError();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSlot(reason: string) {
|
||||||
|
const waitMs = Math.max(0, nextRequestAt - Date.now());
|
||||||
|
if (waitMs > 0) console.log(`[wait] Waiting ${waitMs}ms before ${reason}.`);
|
||||||
|
let remaining = waitMs;
|
||||||
|
while (remaining > 0) {
|
||||||
|
assertNotStopped();
|
||||||
|
await sleep(Math.min(remaining, 1000));
|
||||||
|
remaining = Math.max(0, nextRequestAt - Date.now());
|
||||||
|
}
|
||||||
|
nextRequestAt = Date.now() + REQUEST_DELAY_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(error: any) {
|
||||||
|
if (error?.response) {
|
||||||
|
const body = JSON.stringify(error.response.data ?? '');
|
||||||
|
return `HTTP ${error.response.status}: ${body.slice(0, 500)}`;
|
||||||
|
}
|
||||||
|
return error?.message || String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function v3Get<T>(pathname: string): Promise<T> {
|
||||||
|
await waitForSlot(`Olist V3 ${pathname}`);
|
||||||
|
const request = async (forceRefresh: boolean) => axios.get<T>(`${V3_API_BASE_URL}${pathname}`, {
|
||||||
|
headers: { Authorization: `Bearer ${await getOlistV3AccessToken(forceRefresh)}` },
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await request(false)).data;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status !== 401) throw error;
|
||||||
|
await waitForSlot(`Olist V3 ${pathname} retry after token refresh`);
|
||||||
|
return (await request(true)).data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProduct(id: string): Promise<V3Product> {
|
||||||
|
const raw = await v3Get<any>(`/produtos/${encodeURIComponent(id)}`);
|
||||||
|
return {
|
||||||
|
id: text(raw?.id || id),
|
||||||
|
sku: text(raw?.sku),
|
||||||
|
descricao: text(raw?.descricao),
|
||||||
|
unidade: text(raw?.unidade)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getComponentProduct(id: string) {
|
||||||
|
if (componentProductCache.has(id)) return componentProductCache.get(id) || null;
|
||||||
|
try {
|
||||||
|
const product = await getProduct(id);
|
||||||
|
componentProductCache.set(id, product);
|
||||||
|
return product;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn(`[Olist V3] Could not get component ${id}: ${describeError(error)}`);
|
||||||
|
componentProductCache.set(id, null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFabricatedProduct(id: string): Promise<V3FabricatedProduct | null> {
|
||||||
|
try {
|
||||||
|
return await v3Get<V3FabricatedProduct>(`/produtos/${encodeURIComponent(id)}/fabricado`);
|
||||||
|
} catch (error: any) {
|
||||||
|
if ([400, 404].includes(error?.response?.status)) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDecimal(value: unknown) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? String(parsed) : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildPayload(productId: string): Promise<StructurePayload | null> {
|
||||||
|
const product = await getProduct(productId);
|
||||||
|
const manufactured = await getFabricatedProduct(product.id);
|
||||||
|
const rawComponents = Array.isArray(manufactured?.produtos) ? manufactured.produtos : [];
|
||||||
|
if (!manufactured || !rawComponents.length) return null;
|
||||||
|
|
||||||
|
const components = [];
|
||||||
|
for (const item of rawComponents) {
|
||||||
|
const componentId = text(item.produto?.id);
|
||||||
|
const componentDetail = componentId ? await getComponentProduct(componentId) : null;
|
||||||
|
const quantity = formatDecimal(item.quantidade);
|
||||||
|
components.push({
|
||||||
|
componentTinyId: componentId,
|
||||||
|
componentName: text(item.produto?.descricao || componentDetail?.descricao),
|
||||||
|
componentSku: text(item.produto?.sku || componentDetail?.sku),
|
||||||
|
quantityPerUnit: quantity,
|
||||||
|
totalQuantity: quantity,
|
||||||
|
unit: text(componentDetail?.unidade)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
order: {
|
||||||
|
tinyId: `STRUCTURE-V3-${product.id}`,
|
||||||
|
number: `STRUCTURE-V3-${product.sku || product.id}`,
|
||||||
|
status: 'completed',
|
||||||
|
productSku: product.sku,
|
||||||
|
productDescription: product.descricao,
|
||||||
|
quantity: '1',
|
||||||
|
unit: product.unidade || 'UN',
|
||||||
|
issueDate: null,
|
||||||
|
expectedDate: null,
|
||||||
|
supplier: '',
|
||||||
|
lotCode: '',
|
||||||
|
notes: `Composição sincronizada da API pública Olist V3 para produto ${product.id}.`
|
||||||
|
},
|
||||||
|
components: components.filter(component => component.componentSku || component.componentName),
|
||||||
|
steps: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendToGraphs(payload: StructurePayload) {
|
||||||
|
if (DRY_RUN) {
|
||||||
|
console.log(`[dry-run] ${JSON.stringify(payload)}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 1; ; attempt += 1) {
|
||||||
|
try {
|
||||||
|
await axios.post(GRAPHS_URL, payload, {
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-api-key': GRAPHS_API_KEY },
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
console.log(`[Graphs V3] Sent ${payload.order.productSku} with ${payload.components.length} component(s).`);
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
const retryable = !error?.response?.status || error.response.status >= 500;
|
||||||
|
console.error(`[Graphs V3] Send failed for ${payload.order.productSku}, attempt ${attempt}: ${describeError(error)}`);
|
||||||
|
if (!retryable || (GRAPHS_MAX_RETRIES && attempt >= GRAPHS_MAX_RETRIES)) return false;
|
||||||
|
await sleep(GRAPHS_RETRY_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
if (!isOlistV3Configured()) {
|
||||||
|
throw new Error('Olist V3 is not configured. Set OLIST_V3_CLIENT_ID, OLIST_V3_CLIENT_SECRET, and OLIST_V3_REDIRECT_URI.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = loadRequestedProducts();
|
||||||
|
if (!products.length) throw new Error('No V3 products configured.');
|
||||||
|
await getOlistV3AccessToken();
|
||||||
|
|
||||||
|
const sourceSignature = crypto.createHash('sha256').update(products.map(product => product.id).join('\n')).digest('hex');
|
||||||
|
const previous = readState();
|
||||||
|
const canResume = RESUME
|
||||||
|
&& !PRODUCT_IDS.length
|
||||||
|
&& previous?.status !== 'completed'
|
||||||
|
&& previous?.sourceSignature === sourceSignature
|
||||||
|
&& Number.isInteger(previous?.nextIndex)
|
||||||
|
&& (previous?.nextIndex || 0) > 0
|
||||||
|
&& (previous?.nextIndex || 0) < products.length;
|
||||||
|
let nextIndex = canResume ? previous!.nextIndex! : 0;
|
||||||
|
let processedProducts = canResume ? previous?.processedProducts || 0 : 0;
|
||||||
|
let skippedProducts = canResume ? previous?.skippedProducts || 0 : 0;
|
||||||
|
let failedProducts = canResume ? previous?.failedProducts || 0 : 0;
|
||||||
|
let handledThisRun = 0;
|
||||||
|
let reachedLimit = false;
|
||||||
|
|
||||||
|
console.log(`[config] Olist V3 product structures: ${products.length} product(s), ${REQUEST_DELAY_MS}ms request pace.`);
|
||||||
|
if (canResume) console.log(`[state] Resuming at ${nextIndex + 1}/${products.length}.`);
|
||||||
|
|
||||||
|
const checkpoint = (state: Partial<SyncState>) => saveState({
|
||||||
|
status: 'running',
|
||||||
|
sourceSignature,
|
||||||
|
totalProducts: products.length,
|
||||||
|
nextIndex,
|
||||||
|
processedProducts,
|
||||||
|
skippedProducts,
|
||||||
|
failedProducts,
|
||||||
|
...state
|
||||||
|
});
|
||||||
|
checkpoint({ lastMessage: canResume ? 'V3 structure sync resumed' : 'V3 structure sync started' });
|
||||||
|
|
||||||
|
for (let index = nextIndex; index < products.length; index += 1) {
|
||||||
|
assertNotStopped();
|
||||||
|
if (MAX_PRODUCTS && handledThisRun >= MAX_PRODUCTS) {
|
||||||
|
reachedLimit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productId = products[index].id;
|
||||||
|
nextIndex = index + 1;
|
||||||
|
try {
|
||||||
|
const payload = await buildPayload(productId);
|
||||||
|
if (!payload?.components.length) {
|
||||||
|
skippedProducts += 1;
|
||||||
|
console.log(`[Olist V3] No manufactured-product composition for ${productId}; skipping Graphs send.`);
|
||||||
|
checkpoint({ lastProductId: productId, lastMessage: `Skipped ${productId}; no composition` });
|
||||||
|
} else if (await sendToGraphs(payload)) {
|
||||||
|
processedProducts += 1;
|
||||||
|
checkpoint({ lastProductId: productId, lastMessage: `Synced ${payload.order.productSku}` });
|
||||||
|
} else {
|
||||||
|
failedProducts += 1;
|
||||||
|
checkpoint({ lastFailedProductId: productId, lastMessage: `Graphs send failed for ${productId}` });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof SyncStoppedError) throw error;
|
||||||
|
failedProducts += 1;
|
||||||
|
console.error(`[sync] Failed product ${productId}: ${describeError(error)}`);
|
||||||
|
checkpoint({ lastFailedProductId: productId, lastMessage: `Failed ${productId}; continuing` });
|
||||||
|
}
|
||||||
|
handledThisRun += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveState({
|
||||||
|
status: reachedLimit ? 'stopped' : 'completed',
|
||||||
|
sourceSignature,
|
||||||
|
totalProducts: products.length,
|
||||||
|
nextIndex,
|
||||||
|
processedProducts,
|
||||||
|
skippedProducts,
|
||||||
|
failedProducts,
|
||||||
|
lastMessage: reachedLimit ? `V3 structure sync paused after ${handledThisRun} product(s)` : 'V3 structure sync completed'
|
||||||
|
});
|
||||||
|
console.log(`Done. V3 synced ${processedProducts}; skipped ${skippedProducts}; failed ${failedProducts}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error: any) => {
|
||||||
|
if (error instanceof SyncStoppedError) {
|
||||||
|
saveState({ ...latestState, status: 'stopped', lastMessage: 'V3 structure sync stopped by request' });
|
||||||
|
console.log('V3 product structure sync stopped by request.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.error(`[sync] V3 product structure sync failed: ${describeError(error)}`);
|
||||||
|
saveState({ ...latestState, status: 'stopped', lastMessage: describeError(error) });
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import crypto from 'crypto';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -47,6 +48,9 @@ type ProductStructurePayload = {
|
|||||||
|
|
||||||
type SyncState = {
|
type SyncState = {
|
||||||
status?: 'running' | 'stopped' | 'completed';
|
status?: 'running' | 'stopped' | 'completed';
|
||||||
|
sourceSignature?: string;
|
||||||
|
totalProducts?: number;
|
||||||
|
nextIndex?: number;
|
||||||
processedProducts?: number;
|
processedProducts?: number;
|
||||||
skippedProducts?: number;
|
skippedProducts?: number;
|
||||||
failedProducts?: number;
|
failedProducts?: number;
|
||||||
@@ -120,9 +124,11 @@ 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_RETRY_DELAY_MS = numberEnv('PRODUCT_STRUCTURES_GRAPHS_RETRY_DELAY_MS', 30000);
|
||||||
const GRAPHS_MAX_RETRIES = numberEnv('PRODUCT_STRUCTURES_GRAPHS_MAX_RETRIES', 3);
|
const GRAPHS_MAX_RETRIES = numberEnv('PRODUCT_STRUCTURES_GRAPHS_MAX_RETRIES', 3);
|
||||||
const MAX_PRODUCTS = numberEnv('PRODUCT_STRUCTURES_MAX_PRODUCTS', 0);
|
const MAX_PRODUCTS = numberEnv('PRODUCT_STRUCTURES_MAX_PRODUCTS', 0);
|
||||||
|
const RESUME = process.env.PRODUCT_STRUCTURES_RESUME !== 'false';
|
||||||
|
|
||||||
let nextRequestAt = 0;
|
let nextRequestAt = 0;
|
||||||
const productDetailCache = new Map<string, TinyProduct | null>();
|
const productDetailCache = new Map<string, TinyProduct | null>();
|
||||||
|
let latestState: SyncState | null = null;
|
||||||
|
|
||||||
class SyncStoppedError extends Error {
|
class SyncStoppedError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -159,10 +165,23 @@ async function waitForRequestSlot(reason: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveState(state: SyncState) {
|
function saveState(state: SyncState) {
|
||||||
fs.writeFileSync(STATE_FILE, JSON.stringify({
|
latestState = {
|
||||||
...state,
|
...state,
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
}, null, 2));
|
};
|
||||||
|
fs.writeFileSync(STATE_FILE, JSON.stringify(latestState, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readState(): SyncState | null {
|
||||||
|
if (!fs.existsSync(STATE_FILE)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
||||||
|
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.warn(`[state] Could not read previous state; starting from the beginning: ${error.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeRequestError(error: any) {
|
function describeRequestError(error: any) {
|
||||||
@@ -457,29 +476,58 @@ async function runSync() {
|
|||||||
console.log(`[config] Graphs URL: ${GRAPHS_PRODUCTION_ORDERS_URL}`);
|
console.log(`[config] Graphs URL: ${GRAPHS_PRODUCTION_ORDERS_URL}`);
|
||||||
console.log(`[config] Dry run: ${DRY_RUN ? 'yes' : 'no'}`);
|
console.log(`[config] Dry run: ${DRY_RUN ? 'yes' : 'no'}`);
|
||||||
console.log(`[config] Request pace: 1 request every ${REQUEST_DELAY_MS}ms`);
|
console.log(`[config] Request pace: 1 request every ${REQUEST_DELAY_MS}ms`);
|
||||||
|
console.log(`[config] Resume: ${RESUME ? 'yes' : 'no'}`);
|
||||||
console.log(`[config] State file: ${STATE_FILE}`);
|
console.log(`[config] State file: ${STATE_FILE}`);
|
||||||
console.log(`[config] Stop file: ${STOP_FILE}`);
|
console.log(`[config] Stop file: ${STOP_FILE}`);
|
||||||
console.log(`[source] Found ${requestedProducts.length} requested product(s).`);
|
console.log(`[source] Found ${requestedProducts.length} requested product(s).`);
|
||||||
|
|
||||||
|
const sourceSignature = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(requestedProducts.map(product => `${product.id}|${product.sku}`).join('\n'))
|
||||||
|
.digest('hex');
|
||||||
|
const previousState = readState();
|
||||||
|
const canResume = RESUME
|
||||||
|
&& !SKUS.length
|
||||||
|
&& !PRODUCT_IDS.length
|
||||||
|
&& previousState?.status !== 'completed'
|
||||||
|
&& previousState?.sourceSignature === sourceSignature
|
||||||
|
&& Number.isInteger(previousState?.nextIndex)
|
||||||
|
&& (previousState?.nextIndex || 0) > 0
|
||||||
|
&& (previousState?.nextIndex || 0) < requestedProducts.length;
|
||||||
|
const startIndex = canResume ? previousState!.nextIndex! : 0;
|
||||||
|
let processedProducts = canResume ? previousState?.processedProducts || 0 : 0;
|
||||||
|
let skippedProducts = canResume ? previousState?.skippedProducts || 0 : 0;
|
||||||
|
let failedProducts = canResume ? previousState?.failedProducts || 0 : 0;
|
||||||
|
let handledThisRun = 0;
|
||||||
|
let nextIndex = startIndex;
|
||||||
|
let reachedLimit = false;
|
||||||
|
|
||||||
|
if (canResume) {
|
||||||
|
console.log(`[state] Resuming at ${startIndex + 1}/${requestedProducts.length} after ${previousState?.lastSku || 'the last checkpoint'}.`);
|
||||||
|
}
|
||||||
|
|
||||||
saveState({
|
saveState({
|
||||||
status: 'running',
|
status: 'running',
|
||||||
processedProducts: 0,
|
sourceSignature,
|
||||||
skippedProducts: 0,
|
totalProducts: requestedProducts.length,
|
||||||
failedProducts: 0,
|
nextIndex: startIndex,
|
||||||
lastMessage: 'Product structures sync started'
|
processedProducts,
|
||||||
|
skippedProducts,
|
||||||
|
failedProducts,
|
||||||
|
lastSku: canResume ? previousState?.lastSku : undefined,
|
||||||
|
lastMessage: canResume ? 'Product structures sync resumed' : 'Product structures sync started'
|
||||||
});
|
});
|
||||||
|
|
||||||
let processedProducts = 0;
|
for (let index = startIndex; index < requestedProducts.length; index += 1) {
|
||||||
let skippedProducts = 0;
|
|
||||||
let failedProducts = 0;
|
|
||||||
|
|
||||||
for (const requested of requestedProducts) {
|
|
||||||
assertNotStopped();
|
assertNotStopped();
|
||||||
if (MAX_PRODUCTS && processedProducts + skippedProducts + failedProducts >= MAX_PRODUCTS) {
|
if (MAX_PRODUCTS && handledThisRun >= MAX_PRODUCTS) {
|
||||||
console.log(`[limit] Stopped after ${MAX_PRODUCTS} product(s).`);
|
console.log(`[limit] Stopped after ${MAX_PRODUCTS} product(s).`);
|
||||||
|
reachedLimit = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextIndex = index + 1;
|
||||||
|
const requested = requestedProducts[index];
|
||||||
const label = requested.sku || requested.id;
|
const label = requested.sku || requested.id;
|
||||||
try {
|
try {
|
||||||
const product = await resolveProduct(requested);
|
const product = await resolveProduct(requested);
|
||||||
@@ -488,12 +536,16 @@ async function runSync() {
|
|||||||
failedProducts += 1;
|
failedProducts += 1;
|
||||||
saveState({
|
saveState({
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
sourceSignature,
|
||||||
|
totalProducts: requestedProducts.length,
|
||||||
|
nextIndex,
|
||||||
processedProducts,
|
processedProducts,
|
||||||
skippedProducts,
|
skippedProducts,
|
||||||
failedProducts,
|
failedProducts,
|
||||||
lastFailedSku: label,
|
lastFailedSku: label,
|
||||||
lastMessage: `Product not found for ${label}`
|
lastMessage: `Product not found for ${label}`
|
||||||
});
|
});
|
||||||
|
handledThisRun += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,12 +554,16 @@ async function runSync() {
|
|||||||
failedProducts += 1;
|
failedProducts += 1;
|
||||||
saveState({
|
saveState({
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
sourceSignature,
|
||||||
|
totalProducts: requestedProducts.length,
|
||||||
|
nextIndex,
|
||||||
processedProducts,
|
processedProducts,
|
||||||
skippedProducts,
|
skippedProducts,
|
||||||
failedProducts,
|
failedProducts,
|
||||||
lastFailedSku: product.codigo || product.id,
|
lastFailedSku: product.codigo || product.id,
|
||||||
lastMessage: `Could not get structure for ${product.codigo || product.id}`
|
lastMessage: `Could not get structure for ${product.codigo || product.id}`
|
||||||
});
|
});
|
||||||
|
handledThisRun += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,12 +572,16 @@ async function runSync() {
|
|||||||
console.log(`[Tiny] No structure components for ${payload.order.productSku}; skipping Graphs send.`);
|
console.log(`[Tiny] No structure components for ${payload.order.productSku}; skipping Graphs send.`);
|
||||||
saveState({
|
saveState({
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
sourceSignature,
|
||||||
|
totalProducts: requestedProducts.length,
|
||||||
|
nextIndex,
|
||||||
processedProducts,
|
processedProducts,
|
||||||
skippedProducts,
|
skippedProducts,
|
||||||
failedProducts,
|
failedProducts,
|
||||||
lastSku: payload.order.productSku,
|
lastSku: payload.order.productSku,
|
||||||
lastMessage: `Skipped ${payload.order.productSku}; no structure components`
|
lastMessage: `Skipped ${payload.order.productSku}; no structure components`
|
||||||
});
|
});
|
||||||
|
handledThisRun += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,6 +594,9 @@ async function runSync() {
|
|||||||
|
|
||||||
saveState({
|
saveState({
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
sourceSignature,
|
||||||
|
totalProducts: requestedProducts.length,
|
||||||
|
nextIndex,
|
||||||
processedProducts,
|
processedProducts,
|
||||||
skippedProducts,
|
skippedProducts,
|
||||||
failedProducts,
|
failedProducts,
|
||||||
@@ -541,35 +604,46 @@ async function runSync() {
|
|||||||
lastFailedSku: sent ? undefined : payload.order.productSku,
|
lastFailedSku: sent ? undefined : payload.order.productSku,
|
||||||
lastMessage: sent ? `Synced structure ${payload.order.productSku}` : `Failed structure ${payload.order.productSku}; continuing`
|
lastMessage: sent ? `Synced structure ${payload.order.productSku}` : `Failed structure ${payload.order.productSku}; continuing`
|
||||||
});
|
});
|
||||||
|
handledThisRun += 1;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error instanceof SyncStoppedError) throw error;
|
if (error instanceof SyncStoppedError) throw error;
|
||||||
failedProducts += 1;
|
failedProducts += 1;
|
||||||
console.error(`[sync] Failed product ${label}: ${describeRequestError(error)}`);
|
console.error(`[sync] Failed product ${label}: ${describeRequestError(error)}`);
|
||||||
saveState({
|
saveState({
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
sourceSignature,
|
||||||
|
totalProducts: requestedProducts.length,
|
||||||
|
nextIndex,
|
||||||
processedProducts,
|
processedProducts,
|
||||||
skippedProducts,
|
skippedProducts,
|
||||||
failedProducts,
|
failedProducts,
|
||||||
lastFailedSku: label,
|
lastFailedSku: label,
|
||||||
lastMessage: `Failed product ${label}; continuing`
|
lastMessage: `Failed product ${label}; continuing`
|
||||||
});
|
});
|
||||||
|
handledThisRun += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveState({
|
saveState({
|
||||||
status: 'completed',
|
status: reachedLimit ? 'stopped' : 'completed',
|
||||||
|
sourceSignature,
|
||||||
|
totalProducts: requestedProducts.length,
|
||||||
|
nextIndex,
|
||||||
processedProducts,
|
processedProducts,
|
||||||
skippedProducts,
|
skippedProducts,
|
||||||
failedProducts,
|
failedProducts,
|
||||||
lastMessage: failedProducts ? `Product structures sync completed with ${failedProducts} failed product(s)` : 'Product structures sync completed'
|
lastMessage: reachedLimit
|
||||||
|
? `Product structures sync paused after ${handledThisRun} product(s)`
|
||||||
|
: (failedProducts ? `Product structures sync completed with ${failedProducts} failed product(s)` : 'Product structures sync completed')
|
||||||
});
|
});
|
||||||
console.log(`Done. Synced ${processedProducts} product structure(s). Skipped products: ${skippedProducts}. Failed products: ${failedProducts}.`);
|
console.log(`${reachedLimit ? 'Paused' : 'Done'}. Synced ${processedProducts} product structure(s). Skipped products: ${skippedProducts}. Failed products: ${failedProducts}.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
runSync().catch((error: any) => {
|
runSync().catch((error: any) => {
|
||||||
if (error instanceof SyncStoppedError) {
|
if (error instanceof SyncStoppedError) {
|
||||||
console.log('Product structures sync stopped by request.');
|
console.log('Product structures sync stopped by request.');
|
||||||
saveState({
|
saveState({
|
||||||
|
...latestState,
|
||||||
status: 'stopped',
|
status: 'stopped',
|
||||||
lastMessage: 'Product structures sync stopped by request'
|
lastMessage: 'Product structures sync stopped by request'
|
||||||
});
|
});
|
||||||
|
|||||||
174
src/services/olist-v3.service.ts
Normal file
174
src/services/olist-v3.service.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
type OlistV3Tokens = {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
expires_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OlistV3AuthorizationState = {
|
||||||
|
state: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const OAUTH_BASE_URL = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect';
|
||||||
|
const TOKEN_URL = `${OAUTH_BASE_URL}/token`;
|
||||||
|
const AUTHORIZE_URL = `${OAUTH_BASE_URL}/auth`;
|
||||||
|
const DEFAULT_TOKEN_FILE = path.join(process.cwd(), 'data-runtime', 'olist_v3_tokens.json');
|
||||||
|
const DEFAULT_STATE_FILE = path.join(process.cwd(), 'data-runtime', 'olist_v3_oauth_state.json');
|
||||||
|
|
||||||
|
function requiredEnv(name: string) {
|
||||||
|
const value = process.env[name]?.trim();
|
||||||
|
if (!value) throw new Error(`Missing ${name}.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenFile() {
|
||||||
|
return process.env.OLIST_V3_TOKEN_FILE || DEFAULT_TOKEN_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStateFile() {
|
||||||
|
return process.env.OLIST_V3_OAUTH_STATE_FILE || DEFAULT_STATE_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureParentDirectory(filePath: string) {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(filePath: string, value: unknown) {
|
||||||
|
ensureParentDirectory(filePath);
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson<T>(filePath: string): T | null {
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientConfig() {
|
||||||
|
return {
|
||||||
|
clientId: requiredEnv('OLIST_V3_CLIENT_ID'),
|
||||||
|
clientSecret: requiredEnv('OLIST_V3_CLIENT_SECRET'),
|
||||||
|
redirectUri: requiredEnv('OLIST_V3_REDIRECT_URI')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTokens(tokens: OlistV3Tokens) {
|
||||||
|
const expiresIn = Number(tokens.expires_in || 0);
|
||||||
|
const expiresAt = Number.isFinite(expiresIn) && expiresIn > 0
|
||||||
|
? new Date(Date.now() + Math.max(0, expiresIn - 60) * 1000).toISOString()
|
||||||
|
: undefined;
|
||||||
|
const previous = readJson<OlistV3Tokens>(getTokenFile());
|
||||||
|
|
||||||
|
writeJson(getTokenFile(), {
|
||||||
|
...previous,
|
||||||
|
...tokens,
|
||||||
|
refresh_token: tokens.refresh_token || previous?.refresh_token,
|
||||||
|
expires_at: expiresAt || previous?.expires_at
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenIsUsable(tokens: OlistV3Tokens) {
|
||||||
|
if (!tokens.access_token) return false;
|
||||||
|
if (!tokens.expires_at) return true;
|
||||||
|
return new Date(tokens.expires_at).getTime() > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestTokens(params: URLSearchParams) {
|
||||||
|
const response = await axios.post<OlistV3Tokens>(TOKEN_URL, params, {
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
timeout: 30000
|
||||||
|
});
|
||||||
|
if (!response.data?.access_token) {
|
||||||
|
throw new Error('Olist V3 OAuth did not return an access token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
saveTokens(response.data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOlistV3Configured() {
|
||||||
|
return Boolean(
|
||||||
|
process.env.OLIST_V3_CLIENT_ID?.trim()
|
||||||
|
&& process.env.OLIST_V3_CLIENT_SECRET?.trim()
|
||||||
|
&& process.env.OLIST_V3_REDIRECT_URI?.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOlistV3AuthorizationUrl() {
|
||||||
|
const { clientId, redirectUri } = getClientConfig();
|
||||||
|
const state = crypto.randomBytes(32).toString('hex');
|
||||||
|
writeJson(getStateFile(), { state, createdAt: new Date().toISOString() } satisfies OlistV3AuthorizationState);
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: 'openid',
|
||||||
|
response_type: 'code',
|
||||||
|
state
|
||||||
|
});
|
||||||
|
return `${AUTHORIZE_URL}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exchangeOlistV3AuthorizationCode(code: string, state: string) {
|
||||||
|
const expected = readJson<OlistV3AuthorizationState>(getStateFile());
|
||||||
|
const expectedState = Buffer.from(expected?.state || '');
|
||||||
|
const receivedState = Buffer.from(state || '');
|
||||||
|
if (!expected?.state || expectedState.length !== receivedState.length || !crypto.timingSafeEqual(expectedState, receivedState)) {
|
||||||
|
throw new Error('Invalid OAuth state. Start the authorization flow again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = new Date(expected.createdAt).getTime();
|
||||||
|
if (!Number.isFinite(createdAt) || Date.now() - createdAt > 10 * 60 * 1000) {
|
||||||
|
throw new Error('OAuth state expired. Start the authorization flow again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId, clientSecret, redirectUri } = getClientConfig();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokens = await requestTokens(params);
|
||||||
|
fs.rmSync(getStateFile(), { force: true });
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOlistV3AccessToken(forceRefresh = false) {
|
||||||
|
const current = readJson<OlistV3Tokens>(getTokenFile());
|
||||||
|
if (current && !forceRefresh && tokenIsUsable(current)) {
|
||||||
|
return current.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!current?.refresh_token) {
|
||||||
|
throw new Error('Olist V3 is not authorized. Open /api/olist-v3/authorize first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { clientId, clientSecret } = getClientConfig();
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: 'refresh_token',
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
refresh_token: current.refresh_token
|
||||||
|
});
|
||||||
|
const tokens = await requestTokens(params);
|
||||||
|
return tokens.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOlistV3AuthStatus() {
|
||||||
|
const tokens = readJson<OlistV3Tokens>(getTokenFile());
|
||||||
|
return {
|
||||||
|
configured: isOlistV3Configured(),
|
||||||
|
authorized: Boolean(tokens?.access_token || tokens?.refresh_token),
|
||||||
|
accessTokenUsable: Boolean(tokens && tokenIsUsable(tokens)),
|
||||||
|
tokenExpiresAt: tokens?.expires_at || null,
|
||||||
|
tokenFile: getTokenFile()
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user