Add native Olist composition sync
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s
This commit is contained in:
15
README.md
15
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://<backend-host>/api/olist/oauth/callback
|
||||||
|
OLIST_FRONTEND_URL=https://<frontend-host>
|
||||||
|
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
|
### Cutting Workbook Import
|
||||||
|
|
||||||
Manual cutting/replenishment workbooks can be normalized into JSON before they
|
Manual cutting/replenishment workbooks can be normalized into JSON before they
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ module.exports = {
|
|||||||
JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123',
|
JWT_SECRET: process.env.JWT_SECRET || 'super_secret_jwt_key_123',
|
||||||
DATABASE_URL: process.env.DATABASE_URL || 'postgres://graphuser:graphpassword@localhost:5432/graphdb',
|
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',
|
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_SITE_KEY,
|
||||||
TURNSTILE_SECRET
|
TURNSTILE_SECRET
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS consumption_references (
|
CREATE TABLE IF NOT EXISTS consumption_references (
|
||||||
id SERIAL PRIMARY KEY,
|
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_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_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_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_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(`CREATE INDEX IF NOT EXISTS idx_consumption_references_material_product_id ON consumption_references (material_product_id);`);
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
const { createApp } = require('./server');
|
const { createApp } = require('./server');
|
||||||
const { initDB } = require('./db');
|
const { initDB } = require('./db');
|
||||||
const { PORT } = require('./config');
|
const { OLIST_SYNC_ENABLED, PORT } = require('./config');
|
||||||
|
const { scheduleOlistSync } = require('./services/olistService');
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
await initDB();
|
await initDB();
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
if (OLIST_SYNC_ENABLED) scheduleOlistSync();
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`Nexstar Backend running at http://localhost:${PORT}`);
|
console.log(`Nexstar Backend running at http://localhost:${PORT}`);
|
||||||
console.log(`Endpoint for n8n: POST http://localhost:${PORT}/api/data`);
|
console.log(`Endpoint for n8n: POST http://localhost:${PORT}/api/data`);
|
||||||
|
|||||||
55
backend/routes/olistRoutes.js
Normal file
55
backend/routes/olistRoutes.js
Normal file
@@ -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;
|
||||||
@@ -12,6 +12,7 @@ const productionOrderRoutes = require('./routes/productionOrderRoutes');
|
|||||||
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
|
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
|
||||||
const catalogRoutes = require('./routes/catalogRoutes');
|
const catalogRoutes = require('./routes/catalogRoutes');
|
||||||
const supplyRoutes = require('./routes/supplyRoutes');
|
const supplyRoutes = require('./routes/supplyRoutes');
|
||||||
|
const olistRoutes = require('./routes/olistRoutes');
|
||||||
const databaseDiagnosticRoutes = require('./routes/databaseDiagnosticRoutes');
|
const databaseDiagnosticRoutes = require('./routes/databaseDiagnosticRoutes');
|
||||||
|
|
||||||
const createApp = () => {
|
const createApp = () => {
|
||||||
@@ -28,6 +29,7 @@ const createApp = () => {
|
|||||||
app.use('/api', cuttingSettingsRoutes);
|
app.use('/api', cuttingSettingsRoutes);
|
||||||
app.use('/api', catalogRoutes);
|
app.use('/api', catalogRoutes);
|
||||||
app.use('/api', supplyRoutes);
|
app.use('/api', supplyRoutes);
|
||||||
|
app.use('/api', olistRoutes);
|
||||||
app.use('/api', databaseDiagnosticRoutes);
|
app.use('/api', databaseDiagnosticRoutes);
|
||||||
app.use('/api', analyticsRoutes);
|
app.use('/api', analyticsRoutes);
|
||||||
app.use('/api', userRoutes);
|
app.use('/api', userRoutes);
|
||||||
|
|||||||
389
backend/services/olistService.js
Normal file
389
backend/services/olistService.js
Normal file
@@ -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
|
||||||
|
};
|
||||||
@@ -1210,6 +1210,7 @@ module.exports = {
|
|||||||
listProductCompositions,
|
listProductCompositions,
|
||||||
listProductionOrders,
|
listProductionOrders,
|
||||||
normalizeStatus,
|
normalizeStatus,
|
||||||
|
upsertTinyProductComposition,
|
||||||
upsertTinyProductionOrderDetail,
|
upsertTinyProductionOrderDetail,
|
||||||
updateProductionOrderStatus
|
updateProductionOrderStatus
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ services:
|
|||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
|
- JWT_SECRET=${JWT_SECRET:-super_secret_jwt_key_123}
|
||||||
- N8N_WHATSAPP_TRIGGER_URL=${N8N_WHATSAPP_TRIGGER_URL:-http://localhost:5678/webhook/whatsapp}
|
- 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_SITE_KEY=${TURNSTILE_SITE_KEY:-}
|
||||||
- TURNSTILE_SITEKEY=${TURNSTILE_SITEKEY:-}
|
- TURNSTILE_SITEKEY=${TURNSTILE_SITEKEY:-}
|
||||||
- VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY:-}
|
- VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY:-}
|
||||||
|
|||||||
@@ -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';
|
import { formatDateParam } from './dateRanges';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
@@ -276,6 +276,30 @@ export const fetchCatalogSummary = async (): Promise<CatalogSummary> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchOlistStatus = async (): Promise<OlistStatus> => {
|
||||||
|
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<string> => {
|
||||||
|
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<void> => {
|
||||||
|
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<CatalogCategory> => {
|
export const saveCatalogCategory = async (payload: CatalogCategoryPayload): Promise<CatalogCategory> => {
|
||||||
const response = await authFetch('/catalog/categories', {
|
const response = await authFetch('/catalog/categories', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -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 { 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 {
|
import {
|
||||||
deleteCatalogCategory,
|
deleteCatalogCategory,
|
||||||
deleteCatalogProduct,
|
deleteCatalogProduct,
|
||||||
deleteConsumptionReference,
|
deleteConsumptionReference,
|
||||||
fetchCatalogSummary,
|
fetchCatalogSummary,
|
||||||
|
fetchOlistStatus,
|
||||||
|
getOlistAuthorizationUrl,
|
||||||
importProductCompositions,
|
importProductCompositions,
|
||||||
|
isSuperAdmin,
|
||||||
saveCatalogCategory,
|
saveCatalogCategory,
|
||||||
saveCatalogProduct,
|
saveCatalogProduct,
|
||||||
saveConsumptionReference,
|
saveConsumptionReference,
|
||||||
|
startOlistCompositionSync,
|
||||||
updateCatalogCategory
|
updateCatalogCategory
|
||||||
} from '../dataService';
|
} from '../dataService';
|
||||||
import PaginationControls from '../components/PaginationControls';
|
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';
|
import { formatColorLabel } from '../displayFormatters';
|
||||||
|
|
||||||
type RegistrationTab = 'products' | 'categories' | 'references';
|
type RegistrationTab = 'products' | 'categories' | 'references';
|
||||||
@@ -121,6 +125,8 @@ const Registrations = () => {
|
|||||||
});
|
});
|
||||||
const [status, setStatus] = useState<SaveStatus>('idle');
|
const [status, setStatus] = useState<SaveStatus>('idle');
|
||||||
const [feedback, setFeedback] = useState('');
|
const [feedback, setFeedback] = useState('');
|
||||||
|
const [olistStatus, setOlistStatus] = useState<OlistStatus | null>(null);
|
||||||
|
const [olistActionLoading, setOlistActionLoading] = useState(false);
|
||||||
const appliedSkuPrefillRef = useRef('');
|
const appliedSkuPrefillRef = useRef('');
|
||||||
const compositionFileInputRef = useRef<HTMLInputElement>(null);
|
const compositionFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -139,6 +145,7 @@ const Registrations = () => {
|
|||||||
});
|
});
|
||||||
const [sizeAreas, setSizeAreas] = useState<Record<string, string>>(defaultSizeAreas);
|
const [sizeAreas, setSizeAreas] = useState<Record<string, string>>(defaultSizeAreas);
|
||||||
const [sizeYields, setSizeYields] = useState<Record<string, string>>({});
|
const [sizeYields, setSizeYields] = useState<Record<string, string>>({});
|
||||||
|
const canManageOlist = isSuperAdmin();
|
||||||
|
|
||||||
const loadCatalog = async () => {
|
const loadCatalog = async () => {
|
||||||
setIsLoading(true);
|
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(() => {
|
useEffect(() => {
|
||||||
const requestedTab = searchParams.get('tab');
|
const requestedTab = searchParams.get('tab');
|
||||||
const sku = (searchParams.get('sku') || '').trim();
|
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(
|
const removeCategory = (category: CatalogCategory) => runAction(
|
||||||
() => deleteCatalogCategory(category.id),
|
() => deleteCatalogCategory(category.id),
|
||||||
`Categoria ${category.name} excluida.`
|
`Categoria ${category.name} excluida.`
|
||||||
@@ -440,6 +493,33 @@ const Registrations = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{canManageOlist && olistStatus && (
|
||||||
|
<>
|
||||||
|
{olistStatus.connected ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void syncOlist()}
|
||||||
|
disabled={olistActionLoading || olistStatus.syncInProgress}
|
||||||
|
title="Sincronizar composições da Olist"
|
||||||
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-sky-400/30 bg-sky-400/10 px-4 text-sm font-bold text-sky-200 transition-colors hover:border-sky-300 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
|
||||||
|
>
|
||||||
|
{olistActionLoading || olistStatus.syncInProgress ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />}
|
||||||
|
{olistStatus.syncInProgress ? 'Sincronizando' : 'Sincronizar Olist'}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void connectOlist()}
|
||||||
|
disabled={!olistStatus.configured || olistActionLoading}
|
||||||
|
title={olistStatus.configured ? 'Conectar conta Olist' : 'Configure as credenciais Olist V3 no ambiente'}
|
||||||
|
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
|
||||||
|
>
|
||||||
|
{olistActionLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Link2 className="h-4 w-4 text-brand-primary" />}
|
||||||
|
Conectar Olist
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<input
|
<input
|
||||||
ref={compositionFileInputRef}
|
ref={compositionFileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -505,6 +585,18 @@ const Registrations = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canManageOlist && olistStatus?.runs[0] && (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-xs font-semibold text-dark-muted">
|
||||||
|
<span className={olistStatus.runs[0].status === 'failed' ? 'text-red-300' : olistStatus.runs[0].status === 'running' ? 'text-sky-300' : 'text-emerald-300'}>
|
||||||
|
Olist {olistStatus.runs[0].status === 'running' ? 'sincronizando' : olistStatus.runs[0].status === 'failed' ? 'com falha' : 'sincronizada'}
|
||||||
|
</span>
|
||||||
|
<span>{olistStatus.runs[0].mode === 'full' ? 'Carga completa' : 'Atualização incremental'}</span>
|
||||||
|
<span>{olistStatus.runs[0].imported} composições</span>
|
||||||
|
<span>{olistStatus.runs[0].referenceCount} referências</span>
|
||||||
|
{olistStatus.runs[0].failed > 0 && <span className="text-red-300">{olistStatus.runs[0].failed} falhas</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-48 items-center justify-center rounded-2xl border border-dark-border bg-dark-card text-brand-primary">
|
<div className="flex h-48 items-center justify-center rounded-2xl border border-dark-border bg-dark-card text-brand-primary">
|
||||||
<Loader2 className="h-7 w-7 animate-spin" />
|
<Loader2 className="h-7 w-7 animate-spin" />
|
||||||
|
|||||||
26
src/types.ts
26
src/types.ts
@@ -204,6 +204,32 @@ export type CatalogCategoryPayload = {
|
|||||||
description?: string;
|
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 = {
|
export type CatalogProductPayload = {
|
||||||
type: CatalogProductType;
|
type: CatalogProductType;
|
||||||
sku: string;
|
sku: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user