diff --git a/README.md b/README.md
index 28c93a0..84b986f 100644
--- a/README.md
+++ b/README.md
@@ -52,6 +52,21 @@ The scheduled endpoint is API-key protected and returns a summary:
}
```
+### Native Olist V3 composition sync
+
+Graphs can connect directly to Olist V3 from `Cadastros`, import manufactured-product structures, and create/update the local catalog and consumption references. Configure these backend environment variables in Portainer before connecting:
+
+```text
+OLIST_CLIENT_ID
+OLIST_CLIENT_SECRET
+OLIST_REDIRECT_URI=https:///api/olist/oauth/callback
+OLIST_FRONTEND_URL=https://
+OLIST_TOKEN_ENCRYPTION_KEY=<32-byte base64 key or 64-character hex key>
+OLIST_SYNC_ENABLED=true
+```
+
+`OLIST_TOKEN_ENCRYPTION_KEY` must be stable between deploys because Graphs uses it to encrypt OAuth tokens stored in PostgreSQL. The initial sync is full; later scheduled runs are incremental and use the product update date. Manual JSON import remains available as a fallback.
+
### Cutting Workbook Import
Manual cutting/replenishment workbooks can be normalized into JSON before they
diff --git a/backend/config.js b/backend/config.js
index 8efebf8..36c0322 100644
--- a/backend/config.js
+++ b/backend/config.js
@@ -37,6 +37,14 @@ module.exports = {
JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123',
DATABASE_URL: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
N8N_WHATSAPP_TRIGGER_URL: process.env.N8N_WHATSAPP_TRIGGER_URL || 'http://localhost:5678/webhook/whatsapp',
+ OLIST_CLIENT_ID: process.env.OLIST_CLIENT_ID || '',
+ OLIST_CLIENT_SECRET: process.env.OLIST_CLIENT_SECRET || '',
+ OLIST_REDIRECT_URI: process.env.OLIST_REDIRECT_URI || '',
+ OLIST_FRONTEND_URL: process.env.OLIST_FRONTEND_URL || '',
+ OLIST_TOKEN_ENCRYPTION_KEY: process.env.OLIST_TOKEN_ENCRYPTION_KEY || '',
+ OLIST_SYNC_ENABLED: process.env.OLIST_SYNC_ENABLED === 'true',
+ OLIST_SYNC_INTERVAL_MS: Number(process.env.OLIST_SYNC_INTERVAL_MS) || 24 * 60 * 60 * 1000,
+ OLIST_SYNC_REQUESTS_PER_MINUTE: Number(process.env.OLIST_SYNC_REQUESTS_PER_MINUTE) || 25,
TURNSTILE_SITE_KEY,
TURNSTILE_SECRET
};
diff --git a/backend/db.js b/backend/db.js
index 21b161d..63212e9 100644
--- a/backend/db.js
+++ b/backend/db.js
@@ -277,6 +277,47 @@ const initDB = async () => {
);
`);
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS olist_connections (
+ id SMALLINT PRIMARY KEY CHECK (id = 1),
+ access_token_ciphertext TEXT NOT NULL,
+ access_token_iv VARCHAR(64) NOT NULL,
+ refresh_token_ciphertext TEXT NOT NULL,
+ refresh_token_iv VARCHAR(64) NOT NULL,
+ token_expires_at TIMESTAMPTZ,
+ connected_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
+ );
+ `);
+
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS olist_oauth_states (
+ state_hash VARCHAR(128) PRIMARY KEY,
+ expires_at TIMESTAMPTZ NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
+ );
+ `);
+
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS olist_sync_runs (
+ id SERIAL PRIMARY KEY,
+ trigger VARCHAR(30) NOT NULL,
+ mode VARCHAR(20) NOT NULL,
+ status VARCHAR(20) NOT NULL,
+ started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
+ completed_at TIMESTAMPTZ,
+ products_scanned INTEGER NOT NULL DEFAULT 0,
+ manufactured_products INTEGER NOT NULL DEFAULT 0,
+ imported INTEGER NOT NULL DEFAULT 0,
+ component_count INTEGER NOT NULL DEFAULT 0,
+ reference_count INTEGER NOT NULL DEFAULT 0,
+ skipped_reference_count INTEGER NOT NULL DEFAULT 0,
+ failed INTEGER NOT NULL DEFAULT 0,
+ error_message TEXT,
+ details JSONB NOT NULL DEFAULT '[]'::jsonb
+ );
+ `);
+
await pool.query(`
CREATE TABLE IF NOT EXISTS consumption_references (
id SERIAL PRIMARY KEY,
@@ -554,6 +595,7 @@ const initDB = async () => {
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cutting_product_overrides_family_key ON cutting_product_overrides (family_key);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_type ON catalog_products (type);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_category_id ON catalog_products (category_id);`);
+ await pool.query(`CREATE INDEX IF NOT EXISTS idx_olist_sync_runs_started_at ON olist_sync_runs (started_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_product_id ON consumption_references (product_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_material_product_id ON consumption_references (material_product_id);`);
await pool.query(`
diff --git a/backend/index.js b/backend/index.js
index bc5e0b0..4c798ae 100644
--- a/backend/index.js
+++ b/backend/index.js
@@ -1,11 +1,13 @@
const { createApp } = require('./server');
const { initDB } = require('./db');
-const { PORT } = require('./config');
+const { OLIST_SYNC_ENABLED, PORT } = require('./config');
+const { scheduleOlistSync } = require('./services/olistService');
const start = async () => {
await initDB();
const app = createApp();
+ if (OLIST_SYNC_ENABLED) scheduleOlistSync();
app.listen(PORT, '0.0.0.0', () => {
console.log(`Nexstar Backend running at http://localhost:${PORT}`);
console.log(`Endpoint for n8n: POST http://localhost:${PORT}/api/data`);
diff --git a/backend/routes/olistRoutes.js b/backend/routes/olistRoutes.js
new file mode 100644
index 0000000..caee018
--- /dev/null
+++ b/backend/routes/olistRoutes.js
@@ -0,0 +1,55 @@
+const express = require('express');
+const { verifySuperAdmin } = require('../auth');
+const {
+ completeAuthorization,
+ createAuthorizationUrl,
+ getOlistFrontendRedirect,
+ getOlistStatus,
+ startOlistSync
+} = require('../services/olistService');
+
+const router = express.Router();
+
+router.get('/olist/status', verifySuperAdmin, async (req, res, next) => {
+ try {
+ res.json(await getOlistStatus());
+ } catch (error) {
+ next(error);
+ }
+});
+
+router.post('/olist/authorization-url', verifySuperAdmin, async (req, res, next) => {
+ try {
+ res.json({ url: await createAuthorizationUrl() });
+ } catch (error) {
+ next(error);
+ }
+});
+
+router.post('/olist/sync', verifySuperAdmin, async (req, res, next) => {
+ try {
+ const result = await startOlistSync({
+ trigger: 'manual',
+ fullSync: req.body?.fullSync === true
+ });
+ res.status(result.accepted ? 202 : 409).json(result);
+ } catch (error) {
+ next(error);
+ }
+});
+
+router.get('/olist/oauth/callback', async (req, res) => {
+ try {
+ if (req.query.error || !req.query.code || !req.query.state) {
+ res.redirect(getOlistFrontendRedirect('cancelled'));
+ return;
+ }
+ await completeAuthorization({ code: String(req.query.code), state: String(req.query.state) });
+ res.redirect(getOlistFrontendRedirect('connected'));
+ } catch (error) {
+ console.error('Olist OAuth callback failed:', error.message);
+ res.redirect(getOlistFrontendRedirect('failed'));
+ }
+});
+
+module.exports = router;
diff --git a/backend/server.js b/backend/server.js
index 4d33666..c2c22d4 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -12,6 +12,7 @@ const productionOrderRoutes = require('./routes/productionOrderRoutes');
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
const catalogRoutes = require('./routes/catalogRoutes');
const supplyRoutes = require('./routes/supplyRoutes');
+const olistRoutes = require('./routes/olistRoutes');
const databaseDiagnosticRoutes = require('./routes/databaseDiagnosticRoutes');
const createApp = () => {
@@ -28,6 +29,7 @@ const createApp = () => {
app.use('/api', cuttingSettingsRoutes);
app.use('/api', catalogRoutes);
app.use('/api', supplyRoutes);
+ app.use('/api', olistRoutes);
app.use('/api', databaseDiagnosticRoutes);
app.use('/api', analyticsRoutes);
app.use('/api', userRoutes);
diff --git a/backend/services/olistService.js b/backend/services/olistService.js
new file mode 100644
index 0000000..5b4e2da
--- /dev/null
+++ b/backend/services/olistService.js
@@ -0,0 +1,389 @@
+const crypto = require('crypto');
+const { pool } = require('../db');
+const {
+ OLIST_CLIENT_ID,
+ OLIST_CLIENT_SECRET,
+ OLIST_FRONTEND_URL,
+ OLIST_REDIRECT_URI,
+ OLIST_SYNC_INTERVAL_MS,
+ OLIST_SYNC_REQUESTS_PER_MINUTE,
+ OLIST_TOKEN_ENCRYPTION_KEY
+} = require('../config');
+const { upsertTinyProductComposition } = require('./productionOrderService');
+
+const OLIST_API_URL = 'https://api.tiny.com.br/public-api/v3';
+const OLIST_TOKEN_URL = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect/token';
+const OLIST_AUTHORIZE_URL = 'https://accounts.tiny.com.br/realms/tiny/protocol/openid-connect/auth';
+const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
+const RUN_LOCK_KEY = 8_371_204;
+let activeSync = null;
+let scheduledSync = null;
+
+const normalizeText = (value) => String(value || '').trim();
+const stateHash = (state) => crypto.createHash('sha256').update(state).digest('hex');
+
+const getEncryptionKey = () => {
+ const raw = normalizeText(OLIST_TOKEN_ENCRYPTION_KEY);
+ if (/^[a-f0-9]{64}$/i.test(raw)) return Buffer.from(raw, 'hex');
+ try {
+ const decoded = Buffer.from(raw, 'base64');
+ return decoded.length === 32 ? decoded : null;
+ } catch {
+ return null;
+ }
+};
+
+const isOlistConfigured = () => Boolean(
+ normalizeText(OLIST_CLIENT_ID)
+ && normalizeText(OLIST_CLIENT_SECRET)
+ && normalizeText(OLIST_REDIRECT_URI)
+ && normalizeText(OLIST_FRONTEND_URL)
+ && getEncryptionKey()
+);
+
+const configuredError = () => {
+ const error = new Error('Configure as credenciais Olist V3 e a chave de criptografia no ambiente antes de conectar.');
+ error.statusCode = 409;
+ return error;
+};
+
+const assertConfigured = () => {
+ if (!isOlistConfigured()) throw configuredError();
+};
+
+const encrypt = (value) => {
+ const key = getEncryptionKey();
+ if (!key) throw configuredError();
+ const iv = crypto.randomBytes(12);
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
+ const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
+ return {
+ ciphertext: Buffer.concat([ciphertext, cipher.getAuthTag()]).toString('base64'),
+ iv: iv.toString('base64')
+ };
+};
+
+const decrypt = ({ ciphertext, iv }) => {
+ const key = getEncryptionKey();
+ if (!key) throw configuredError();
+ const payload = Buffer.from(ciphertext, 'base64');
+ const authTag = payload.subarray(-16);
+ const encrypted = payload.subarray(0, -16);
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));
+ decipher.setAuthTag(authTag);
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
+};
+
+const mapRun = (row) => ({
+ id: row.id,
+ trigger: row.trigger,
+ mode: row.mode,
+ status: row.status,
+ startedAt: row.started_at,
+ completedAt: row.completed_at,
+ productsScanned: Number(row.products_scanned || 0),
+ manufacturedProducts: Number(row.manufactured_products || 0),
+ imported: Number(row.imported || 0),
+ componentCount: Number(row.component_count || 0),
+ referenceCount: Number(row.reference_count || 0),
+ skippedReferenceCount: Number(row.skipped_reference_count || 0),
+ failed: Number(row.failed || 0),
+ errorMessage: row.error_message || '',
+ details: row.details || []
+});
+
+const getConnection = async () => {
+ const result = await pool.query('SELECT * FROM olist_connections WHERE id = 1;');
+ return result.rows[0] || null;
+};
+
+const listSyncRuns = async (limit = 5) => {
+ const result = await pool.query(`
+ SELECT * FROM olist_sync_runs
+ ORDER BY started_at DESC
+ LIMIT $1;
+ `, [Math.max(1, Math.min(Number(limit) || 5, 20))]);
+ return result.rows.map(mapRun);
+};
+
+const getOlistStatus = async () => {
+ const [connection, runs] = await Promise.all([getConnection(), listSyncRuns()]);
+ return {
+ configured: isOlistConfigured(),
+ connected: Boolean(connection),
+ tokenExpiresAt: connection?.token_expires_at || null,
+ syncInProgress: Boolean(activeSync),
+ runs
+ };
+};
+
+const createAuthorizationUrl = async () => {
+ assertConfigured();
+ const state = crypto.randomBytes(32).toString('base64url');
+ await pool.query('DELETE FROM olist_oauth_states WHERE expires_at <= CURRENT_TIMESTAMP;');
+ await pool.query(`
+ INSERT INTO olist_oauth_states (state_hash, expires_at)
+ VALUES ($1, $2);
+ `, [stateHash(state), new Date(Date.now() + OAUTH_STATE_TTL_MS)]);
+
+ const params = new URLSearchParams({
+ client_id: OLIST_CLIENT_ID,
+ redirect_uri: OLIST_REDIRECT_URI,
+ scope: 'openid',
+ response_type: 'code',
+ state
+ });
+ return `${OLIST_AUTHORIZE_URL}?${params.toString()}`;
+};
+
+const exchangeToken = async (params) => {
+ const response = await fetch(OLIST_TOKEN_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ client_id: OLIST_CLIENT_ID,
+ client_secret: OLIST_CLIENT_SECRET,
+ ...params
+ })
+ });
+ const data = await response.json().catch(() => null);
+ if (!response.ok || !data?.access_token) {
+ const error = new Error(data?.error_description || data?.error || 'Não foi possível autorizar a conexão com a Olist.');
+ error.statusCode = 502;
+ throw error;
+ }
+ return data;
+};
+
+const persistTokens = async ({ accessToken, refreshToken, expiresIn, existingConnection }) => {
+ const access = encrypt(accessToken);
+ const refresh = refreshToken ? encrypt(refreshToken) : {
+ ciphertext: existingConnection.refresh_token_ciphertext,
+ iv: existingConnection.refresh_token_iv
+ };
+ const expiresAt = new Date(Date.now() + Math.max(Number(expiresIn) || 14_400, 60) * 1000);
+ await pool.query(`
+ INSERT INTO olist_connections (
+ id, access_token_ciphertext, access_token_iv, refresh_token_ciphertext,
+ refresh_token_iv, token_expires_at, connected_at, updated_at
+ )
+ VALUES (1, $1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
+ ON CONFLICT (id) DO UPDATE
+ SET access_token_ciphertext = EXCLUDED.access_token_ciphertext,
+ access_token_iv = EXCLUDED.access_token_iv,
+ refresh_token_ciphertext = EXCLUDED.refresh_token_ciphertext,
+ refresh_token_iv = EXCLUDED.refresh_token_iv,
+ token_expires_at = EXCLUDED.token_expires_at,
+ updated_at = CURRENT_TIMESTAMP;
+ `, [access.ciphertext, access.iv, refresh.ciphertext, refresh.iv, expiresAt]);
+};
+
+const completeAuthorization = async ({ code, state }) => {
+ assertConfigured();
+ const result = await pool.query(`
+ DELETE FROM olist_oauth_states
+ WHERE state_hash = $1 AND expires_at > CURRENT_TIMESTAMP
+ RETURNING state_hash;
+ `, [stateHash(state)]);
+ if (!result.rows.length) {
+ const error = new Error('A autorização da Olist expirou ou é inválida. Tente conectar novamente.');
+ error.statusCode = 400;
+ throw error;
+ }
+
+ const token = await exchangeToken({
+ grant_type: 'authorization_code',
+ redirect_uri: OLIST_REDIRECT_URI,
+ code
+ });
+ if (!token.refresh_token) {
+ const error = new Error('A Olist não retornou um refresh token. Verifique as permissões do aplicativo.');
+ error.statusCode = 502;
+ throw error;
+ }
+ await persistTokens({ accessToken: token.access_token, refreshToken: token.refresh_token, expiresIn: token.expires_in, existingConnection: {} });
+};
+
+const getAccessToken = async (forceRefresh = false) => {
+ assertConfigured();
+ const connection = await getConnection();
+ if (!connection) {
+ const error = new Error('Conecte a conta Olist antes de sincronizar.');
+ error.statusCode = 409;
+ throw error;
+ }
+
+ if (!forceRefresh && new Date(connection.token_expires_at).getTime() > Date.now() + 60_000) {
+ return decrypt({ ciphertext: connection.access_token_ciphertext, iv: connection.access_token_iv });
+ }
+
+ const token = await exchangeToken({
+ grant_type: 'refresh_token',
+ refresh_token: decrypt({ ciphertext: connection.refresh_token_ciphertext, iv: connection.refresh_token_iv })
+ });
+ await persistTokens({ accessToken: token.access_token, refreshToken: token.refresh_token, expiresIn: token.expires_in, existingConnection: connection });
+ return token.access_token;
+};
+
+const olistRequest = async (path, retry = true) => {
+ const response = await fetch(`${OLIST_API_URL}${path}`, {
+ headers: { Authorization: `Bearer ${await getAccessToken(!retry)}` }
+ });
+ if (response.status === 401 && retry) return olistRequest(path, false);
+ const data = await response.json().catch(() => null);
+ if (!response.ok) {
+ const error = new Error(data?.mensagem || `A Olist retornou HTTP ${response.status}.`);
+ error.statusCode = response.status;
+ throw error;
+ }
+ return data;
+};
+
+const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
+
+const listOlistProducts = async () => {
+ const products = [];
+ let offset = 0;
+ do {
+ const page = await olistRequest(`/produtos?${new URLSearchParams({ limit: '100', offset: String(offset) })}`);
+ products.push(...(Array.isArray(page?.itens) ? page.itens : []));
+ offset += page?.paginacao?.limit || 100;
+ if (offset >= Number(page?.paginacao?.total || 0)) break;
+ } while (true);
+ return products;
+};
+
+const createRun = async ({ trigger, mode }) => {
+ const result = await pool.query(`
+ INSERT INTO olist_sync_runs (trigger, mode, status)
+ VALUES ($1, $2, 'running')
+ RETURNING *;
+ `, [trigger, mode]);
+ return mapRun(result.rows[0]);
+};
+
+const finishRun = async (id, summary, error = null) => {
+ const result = await pool.query(`
+ UPDATE olist_sync_runs
+ SET status = $2,
+ completed_at = CURRENT_TIMESTAMP,
+ products_scanned = $3,
+ manufactured_products = $4,
+ imported = $5,
+ component_count = $6,
+ reference_count = $7,
+ skipped_reference_count = $8,
+ failed = $9,
+ error_message = $10,
+ details = $11::jsonb
+ WHERE id = $1
+ RETURNING *;
+ `, [id, error ? 'failed' : 'completed', summary.productsScanned, summary.manufacturedProducts, summary.imported, summary.componentCount, summary.referenceCount, summary.skippedReferenceCount, summary.failed, error?.message || null, JSON.stringify(summary.details.slice(0, 100))]);
+ return mapRun(result.rows[0]);
+};
+
+const runOlistSync = async ({ trigger = 'manual', fullSync = false } = {}) => {
+ assertConfigured();
+ if (activeSync) return activeSync;
+
+ activeSync = (async () => {
+ const lastSuccessful = (await listSyncRuns(20)).find(run => run.status === 'completed');
+ const mode = fullSync || !lastSuccessful ? 'full' : 'incremental';
+ const run = await createRun({ trigger, mode });
+ const summary = { productsScanned: 0, manufacturedProducts: 0, imported: 0, componentCount: 0, referenceCount: 0, skippedReferenceCount: 0, failed: 0, details: [] };
+ let lockClient;
+ try {
+ lockClient = await pool.connect();
+ const lock = await lockClient.query('SELECT pg_try_advisory_lock($1) AS locked;', [RUN_LOCK_KEY]);
+ if (!lock.rows[0]?.locked) {
+ const error = new Error('Outra sincronização Olist já está em andamento.');
+ error.statusCode = 409;
+ throw error;
+ }
+
+ const allProducts = await listOlistProducts();
+ const unitById = new Map(allProducts.map(product => [String(product.id), product.unidade || 'UN']));
+ const changedAfter = mode === 'incremental' ? new Date(lastSuccessful.completedAt || lastSuccessful.startedAt) : null;
+ const candidates = changedAfter
+ ? allProducts.filter(product => !product.dataAlteracao || new Date(product.dataAlteracao).getTime() >= changedAfter.getTime())
+ : allProducts;
+ const minimumDelay = Math.ceil(60_000 / Math.max(1, OLIST_SYNC_REQUESTS_PER_MINUTE));
+
+ for (const product of candidates) {
+ summary.productsScanned += 1;
+ try {
+ const structure = await olistRequest(`/produtos/${product.id}/fabricado`);
+ const components = Array.isArray(structure?.produtos) ? structure.produtos : [];
+ if (!components.length) continue;
+ const result = await upsertTinyProductComposition({
+ tinyId: String(product.id),
+ productSku: product.sku,
+ productDescription: product.descricao,
+ unit: product.unidade || 'UN',
+ rawPayload: { product, structure }
+ }, components.map(component => ({
+ componentTinyId: String(component?.produto?.id || ''),
+ componentSku: component?.produto?.sku || '',
+ componentName: component?.produto?.descricao || '',
+ quantityPerUnit: component?.quantidade,
+ unit: unitById.get(String(component?.produto?.id)) || 'UN'
+ })));
+ summary.manufacturedProducts += 1;
+ summary.imported += 1;
+ summary.componentCount += result.componentCount || 0;
+ summary.referenceCount += result.referenceCount || 0;
+ summary.skippedReferenceCount += result.skippedReferenceCount || 0;
+ } catch (error) {
+ if (error.statusCode !== 400 && error.statusCode !== 404) {
+ summary.failed += 1;
+ summary.details.push({ productId: product.id, sku: product.sku || '', error: error.message });
+ }
+ }
+ await sleep(minimumDelay);
+ }
+ return await finishRun(run.id, summary);
+ } catch (error) {
+ await finishRun(run.id, summary, error);
+ throw error;
+ } finally {
+ if (lockClient) {
+ await lockClient.query('SELECT pg_advisory_unlock($1);', [RUN_LOCK_KEY]).catch(() => {});
+ lockClient.release();
+ }
+ }
+ })().finally(() => { activeSync = null; });
+
+ return activeSync;
+};
+
+const startOlistSync = async (options) => {
+ assertConfigured();
+ if (!await getConnection()) {
+ const error = new Error('Conecte a conta Olist antes de sincronizar.');
+ error.statusCode = 409;
+ throw error;
+ }
+ if (activeSync) return { accepted: false, reason: 'running' };
+ runOlistSync(options).catch(error => console.error('Olist sync failed:', error.message));
+ return { accepted: true };
+};
+
+const scheduleOlistSync = () => {
+ if (scheduledSync) clearInterval(scheduledSync);
+ scheduledSync = setInterval(() => {
+ startOlistSync({ trigger: 'scheduled' }).catch(error => console.error('Scheduled Olist sync skipped:', error.message));
+ }, OLIST_SYNC_INTERVAL_MS);
+ return scheduledSync;
+};
+
+const getOlistFrontendRedirect = (outcome) => `${OLIST_FRONTEND_URL.replace(/\/$/, '')}/#/registrations?olist=${outcome}`;
+
+module.exports = {
+ completeAuthorization,
+ createAuthorizationUrl,
+ getOlistFrontendRedirect,
+ getOlistStatus,
+ isOlistConfigured,
+ scheduleOlistSync,
+ startOlistSync
+};
diff --git a/backend/services/productionOrderService.js b/backend/services/productionOrderService.js
index 2b22c0a..6948cee 100644
--- a/backend/services/productionOrderService.js
+++ b/backend/services/productionOrderService.js
@@ -1210,6 +1210,7 @@ module.exports = {
listProductCompositions,
listProductionOrders,
normalizeStatus,
+ upsertTinyProductComposition,
upsertTinyProductionOrderDetail,
updateProductionOrderStatus
};
diff --git a/docker-compose.yml b/docker-compose.yml
index 3528490..111ddc4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -27,6 +27,14 @@ services:
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp}
+ - OLIST_CLIENT_ID=${OLIST_CLIENT_ID:-}
+ - OLIST_CLIENT_SECRET=${OLIST_CLIENT_SECRET:-}
+ - OLIST_REDIRECT_URI=${OLIST_REDIRECT_URI:-}
+ - OLIST_FRONTEND_URL=${OLIST_FRONTEND_URL:-}
+ - OLIST_TOKEN_ENCRYPTION_KEY=${OLIST_TOKEN_ENCRYPTION_KEY:-}
+ - OLIST_SYNC_ENABLED=${OLIST_SYNC_ENABLED:-false}
+ - OLIST_SYNC_INTERVAL_MS=${OLIST_SYNC_INTERVAL_MS:-86400000}
+ - OLIST_SYNC_REQUESTS_PER_MINUTE=${OLIST_SYNC_REQUESTS_PER_MINUTE:-25}
- TURNSTILE_SITE_KEY=${TURNSTILE_SITE_KEY:-}
- TURNSTILE_SITEKEY=${TURNSTILE_SITEKEY:-}
- VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY:-}
diff --git a/src/dataService.ts b/src/dataService.ts
index a4218a2..4565551 100644
--- a/src/dataService.ts
+++ b/src/dataService.ts
@@ -1,4 +1,4 @@
-import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
+import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -276,6 +276,30 @@ export const fetchCatalogSummary = async (): Promise => {
}
};
+export const fetchOlistStatus = async (): Promise => {
+ const response = await authFetch('/olist/status', { cache: 'no-store' });
+ const data = await response.json().catch(() => null);
+ if (!response.ok) throw new Error(data?.error || 'Não foi possível consultar a integração Olist.');
+ return data as OlistStatus;
+};
+
+export const getOlistAuthorizationUrl = async (): Promise => {
+ const response = await authFetch('/olist/authorization-url', { method: 'POST' });
+ const data = await response.json().catch(() => null);
+ if (!response.ok || !data?.url) throw new Error(data?.error || 'Não foi possível iniciar a conexão com a Olist.');
+ return data.url as string;
+};
+
+export const startOlistCompositionSync = async (fullSync = false): Promise => {
+ const response = await authFetch('/olist/sync', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ fullSync })
+ });
+ const data = await response.json().catch(() => null);
+ if (!response.ok) throw new Error(data?.error || 'Não foi possível iniciar a sincronização Olist.');
+};
+
export const saveCatalogCategory = async (payload: CatalogCategoryPayload): Promise => {
const response = await authFetch('/catalog/categories', {
method: 'POST',
diff --git a/src/pages/Registrations.tsx b/src/pages/Registrations.tsx
index 0fba964..06d2f07 100644
--- a/src/pages/Registrations.tsx
+++ b/src/pages/Registrations.tsx
@@ -1,19 +1,23 @@
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
-import { ClipboardList, Loader2, Package, Pencil, RefreshCw, Ruler, Save, Tags, Trash2, Upload } from 'lucide-react';
+import { ClipboardList, CloudDownload, Link2, Loader2, Package, Pencil, RefreshCw, Ruler, Save, Tags, Trash2, Upload } from 'lucide-react';
import {
deleteCatalogCategory,
deleteCatalogProduct,
deleteConsumptionReference,
fetchCatalogSummary,
+ fetchOlistStatus,
+ getOlistAuthorizationUrl,
importProductCompositions,
+ isSuperAdmin,
saveCatalogCategory,
saveCatalogProduct,
saveConsumptionReference,
+ startOlistCompositionSync,
updateCatalogCategory
} from '../dataService';
import PaginationControls from '../components/PaginationControls';
-import type { CatalogCategory, CatalogProduct, CatalogProductType, CatalogSummary, ConsumptionReference } from '../types';
+import type { CatalogCategory, CatalogProduct, CatalogProductType, CatalogSummary, ConsumptionReference, OlistStatus } from '../types';
import { formatColorLabel } from '../displayFormatters';
type RegistrationTab = 'products' | 'categories' | 'references';
@@ -121,6 +125,8 @@ const Registrations = () => {
});
const [status, setStatus] = useState('idle');
const [feedback, setFeedback] = useState('');
+ const [olistStatus, setOlistStatus] = useState(null);
+ const [olistActionLoading, setOlistActionLoading] = useState(false);
const appliedSkuPrefillRef = useRef('');
const compositionFileInputRef = useRef(null);
@@ -139,6 +145,7 @@ const Registrations = () => {
});
const [sizeAreas, setSizeAreas] = useState>(defaultSizeAreas);
const [sizeYields, setSizeYields] = useState>({});
+ const canManageOlist = isSuperAdmin();
const loadCatalog = async () => {
setIsLoading(true);
@@ -164,6 +171,26 @@ const Registrations = () => {
};
}, []);
+ const loadOlistStatus = useCallback(async () => {
+ if (!canManageOlist) return;
+ try {
+ setOlistStatus(await fetchOlistStatus());
+ } catch (error) {
+ console.error('Olist status load failed', error);
+ }
+ }, [canManageOlist]);
+
+ useEffect(() => {
+ const timeoutId = window.setTimeout(() => void loadOlistStatus(), 0);
+ return () => window.clearTimeout(timeoutId);
+ }, [loadOlistStatus]);
+
+ useEffect(() => {
+ if (!canManageOlist || !olistStatus?.syncInProgress) return;
+ const intervalId = window.setInterval(() => void loadOlistStatus(), 5_000);
+ return () => window.clearInterval(intervalId);
+ }, [canManageOlist, loadOlistStatus, olistStatus?.syncInProgress]);
+
useEffect(() => {
const requestedTab = searchParams.get('tab');
const sku = (searchParams.get('sku') || '').trim();
@@ -388,6 +415,32 @@ const Registrations = () => {
}
};
+ const connectOlist = async () => {
+ setOlistActionLoading(true);
+ try {
+ window.location.assign(await getOlistAuthorizationUrl());
+ } catch (error) {
+ setFeedback(error instanceof Error ? error.message : 'Não foi possível iniciar a conexão com a Olist.');
+ setStatus('error');
+ setOlistActionLoading(false);
+ }
+ };
+
+ const syncOlist = async () => {
+ setOlistActionLoading(true);
+ try {
+ await startOlistCompositionSync(!olistStatus?.runs.some(run => run.status === 'completed'));
+ setFeedback('Sincronização Olist iniciada. Os dados serão atualizados ao concluir.');
+ setStatus('saved');
+ await loadOlistStatus();
+ } catch (error) {
+ setFeedback(error instanceof Error ? error.message : 'Não foi possível iniciar a sincronização Olist.');
+ setStatus('error');
+ } finally {
+ setOlistActionLoading(false);
+ }
+ };
+
const removeCategory = (category: CatalogCategory) => runAction(
() => deleteCatalogCategory(category.id),
`Categoria ${category.name} excluida.`
@@ -440,6 +493,33 @@ const Registrations = () => {
+ {canManageOlist && olistStatus && (
+ <>
+ {olistStatus.connected ? (
+
+ ) : (
+
+ )}
+ >
+ )}
{
)}
+ {canManageOlist && olistStatus?.runs[0] && (
+
+
+ Olist {olistStatus.runs[0].status === 'running' ? 'sincronizando' : olistStatus.runs[0].status === 'failed' ? 'com falha' : 'sincronizada'}
+
+ {olistStatus.runs[0].mode === 'full' ? 'Carga completa' : 'Atualização incremental'}
+ {olistStatus.runs[0].imported} composições
+ {olistStatus.runs[0].referenceCount} referências
+ {olistStatus.runs[0].failed > 0 && {olistStatus.runs[0].failed} falhas}
+
+ )}
+
{isLoading ? (
diff --git a/src/types.ts b/src/types.ts
index 2e6ab39..958b90c 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -204,6 +204,32 @@ export type CatalogCategoryPayload = {
description?: string;
};
+export interface OlistSyncRun {
+ id: number;
+ trigger: 'manual' | 'scheduled';
+ mode: 'full' | 'incremental';
+ status: 'running' | 'completed' | 'failed';
+ startedAt: string;
+ completedAt: string | null;
+ productsScanned: number;
+ manufacturedProducts: number;
+ imported: number;
+ componentCount: number;
+ referenceCount: number;
+ skippedReferenceCount: number;
+ failed: number;
+ errorMessage: string;
+ details: Array<{ productId?: number; sku?: string; error?: string }>;
+}
+
+export interface OlistStatus {
+ configured: boolean;
+ connected: boolean;
+ tokenExpiresAt: string | null;
+ syncInProgress: boolean;
+ runs: OlistSyncRun[];
+}
+
export type CatalogProductPayload = {
type: CatalogProductType;
sku: string;