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;