Add Olist sync logs and stop control
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
This commit is contained in:
@@ -318,6 +318,26 @@ const initDB = async () => {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE olist_sync_runs
|
||||||
|
ADD COLUMN IF NOT EXISTS cancel_requested_at TIMESTAMPTZ;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS olist_sync_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
run_id INTEGER NOT NULL REFERENCES olist_sync_runs(id) ON DELETE CASCADE,
|
||||||
|
level VARCHAR(20) NOT NULL DEFAULT 'info',
|
||||||
|
event_type VARCHAR(40) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
olist_product_id BIGINT,
|
||||||
|
sku VARCHAR(100),
|
||||||
|
product_description TEXT,
|
||||||
|
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS olist_product_cache (
|
CREATE TABLE IF NOT EXISTS olist_product_cache (
|
||||||
olist_product_id BIGINT PRIMARY KEY,
|
olist_product_id BIGINT PRIMARY KEY,
|
||||||
@@ -617,6 +637,8 @@ const initDB = async () => {
|
|||||||
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_olist_sync_runs_started_at ON olist_sync_runs (started_at DESC);`);
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_olist_sync_events_run_created ON olist_sync_events (run_id, created_at DESC);`);
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_olist_sync_events_affected_products ON olist_sync_events (run_id, event_type, created_at DESC);`);
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_olist_product_cache_pending_structure ON olist_product_cache (structure_checked_at) WHERE structure_checked_at IS NULL;`);
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_olist_product_cache_pending_structure ON olist_product_cache (structure_checked_at) WHERE structure_checked_at IS NULL;`);
|
||||||
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);`);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ const {
|
|||||||
completeAuthorization,
|
completeAuthorization,
|
||||||
createAuthorizationUrl,
|
createAuthorizationUrl,
|
||||||
getOlistFrontendRedirect,
|
getOlistFrontendRedirect,
|
||||||
|
getOlistRunDetails,
|
||||||
getOlistStatus,
|
getOlistStatus,
|
||||||
|
requestOlistSyncStop,
|
||||||
startOlistSync
|
startOlistSync
|
||||||
} = require('../services/olistService');
|
} = require('../services/olistService');
|
||||||
|
|
||||||
@@ -38,6 +40,25 @@ router.post('/olist/sync', verifySuperAdmin, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/olist/sync/stop', verifySuperAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.status(202).json(await requestOlistSyncStop());
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/olist/runs/:runId', verifySuperAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await getOlistRunDetails(req.params.runId, {
|
||||||
|
eventsPage: req.query.eventsPage,
|
||||||
|
productsPage: req.query.productsPage
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/olist/oauth/callback', async (req, res) => {
|
router.get('/olist/oauth/callback', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.query.error || !req.query.code || !req.query.state) {
|
if (req.query.error || !req.query.code || !req.query.state) {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const OLIST_AUTHORIZE_URL = 'https://accounts.tiny.com.br/realms/tiny/protocol/o
|
|||||||
const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
|
const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
|
||||||
const RUN_LOCK_KEY = 8_371_204;
|
const RUN_LOCK_KEY = 8_371_204;
|
||||||
let activeSync = null;
|
let activeSync = null;
|
||||||
|
let activeRunId = null;
|
||||||
|
let stopRequested = false;
|
||||||
let scheduledSync = null;
|
let scheduledSync = null;
|
||||||
let nextOlistRequestAt = 0;
|
let nextOlistRequestAt = 0;
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ const mapRun = (row) => ({
|
|||||||
status: row.status,
|
status: row.status,
|
||||||
startedAt: row.started_at,
|
startedAt: row.started_at,
|
||||||
completedAt: row.completed_at,
|
completedAt: row.completed_at,
|
||||||
|
cancelRequestedAt: row.cancel_requested_at || null,
|
||||||
productsScanned: Number(row.products_scanned || 0),
|
productsScanned: Number(row.products_scanned || 0),
|
||||||
manufacturedProducts: Number(row.manufactured_products || 0),
|
manufacturedProducts: Number(row.manufactured_products || 0),
|
||||||
imported: Number(row.imported || 0),
|
imported: Number(row.imported || 0),
|
||||||
@@ -107,6 +110,90 @@ const listSyncRuns = async (limit = 5) => {
|
|||||||
return result.rows.map(mapRun);
|
return result.rows.map(mapRun);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const logRunEvent = async (runId, {
|
||||||
|
level = 'info',
|
||||||
|
eventType,
|
||||||
|
message,
|
||||||
|
productId = null,
|
||||||
|
sku = '',
|
||||||
|
productDescription = '',
|
||||||
|
metadata = {}
|
||||||
|
}) => {
|
||||||
|
const result = await pool.query(`
|
||||||
|
INSERT INTO olist_sync_events (
|
||||||
|
run_id, level, event_type, message, olist_product_id, sku, product_description, metadata
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
|
||||||
|
RETURNING *;
|
||||||
|
`, [runId, level, eventType, message, productId ? String(productId) : null, sku || null, productDescription || null, JSON.stringify(metadata)]);
|
||||||
|
return result.rows[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCancellationRequested = async (runId) => {
|
||||||
|
const result = await pool.query('SELECT cancel_requested_at FROM olist_sync_runs WHERE id = $1;', [runId]);
|
||||||
|
return Boolean(result.rows[0]?.cancel_requested_at);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapEvent = (row) => ({
|
||||||
|
id: Number(row.id),
|
||||||
|
level: row.level,
|
||||||
|
eventType: row.event_type,
|
||||||
|
message: row.message,
|
||||||
|
productId: row.olist_product_id ? String(row.olist_product_id) : null,
|
||||||
|
sku: row.sku || '',
|
||||||
|
productDescription: row.product_description || '',
|
||||||
|
metadata: row.metadata || {},
|
||||||
|
createdAt: row.created_at
|
||||||
|
});
|
||||||
|
|
||||||
|
const getOlistRunDetails = async (runId, { eventsPage = 1, productsPage = 1, pageSize = 20 } = {}) => {
|
||||||
|
const normalizedRunId = Number(runId);
|
||||||
|
const normalizedPageSize = Math.max(5, Math.min(Number(pageSize) || 20, 50));
|
||||||
|
const normalizePage = (page) => Math.max(1, Number(page) || 1);
|
||||||
|
const normalizedEventsPage = normalizePage(eventsPage);
|
||||||
|
const normalizedProductsPage = normalizePage(productsPage);
|
||||||
|
const runResult = await pool.query('SELECT * FROM olist_sync_runs WHERE id = $1;', [normalizedRunId]);
|
||||||
|
if (!runResult.rows[0]) {
|
||||||
|
const error = new Error('Execução Olist não encontrada.');
|
||||||
|
error.statusCode = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const [eventsResult, eventsCountResult, productsResult, productsCountResult] = await Promise.all([
|
||||||
|
pool.query(`
|
||||||
|
SELECT * FROM olist_sync_events
|
||||||
|
WHERE run_id = $1
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT $2 OFFSET $3;
|
||||||
|
`, [normalizedRunId, normalizedPageSize, (normalizedEventsPage - 1) * normalizedPageSize]),
|
||||||
|
pool.query('SELECT COUNT(*)::int AS count FROM olist_sync_events WHERE run_id = $1;', [normalizedRunId]),
|
||||||
|
pool.query(`
|
||||||
|
SELECT * FROM olist_sync_events
|
||||||
|
WHERE run_id = $1 AND event_type = 'product_imported'
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT $2 OFFSET $3;
|
||||||
|
`, [normalizedRunId, normalizedPageSize, (normalizedProductsPage - 1) * normalizedPageSize]),
|
||||||
|
pool.query(`
|
||||||
|
SELECT COUNT(*)::int AS count FROM olist_sync_events
|
||||||
|
WHERE run_id = $1 AND event_type = 'product_imported';
|
||||||
|
`, [normalizedRunId])
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
run: mapRun(runResult.rows[0]),
|
||||||
|
events: {
|
||||||
|
items: eventsResult.rows.map(mapEvent),
|
||||||
|
total: Number(eventsCountResult.rows[0].count || 0),
|
||||||
|
page: normalizedEventsPage,
|
||||||
|
pageSize: normalizedPageSize
|
||||||
|
},
|
||||||
|
affectedProducts: {
|
||||||
|
items: productsResult.rows.map(mapEvent),
|
||||||
|
total: Number(productsCountResult.rows[0].count || 0),
|
||||||
|
page: normalizedProductsPage,
|
||||||
|
pageSize: normalizedPageSize
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const getCatalogSyncState = async () => {
|
const getCatalogSyncState = async () => {
|
||||||
const result = await pool.query('SELECT * FROM olist_sync_state WHERE id = 1;');
|
const result = await pool.query('SELECT * FROM olist_sync_state WHERE id = 1;');
|
||||||
return result.rows[0] || null;
|
return result.rows[0] || null;
|
||||||
@@ -291,10 +378,11 @@ const formatOlistDate = (value) => {
|
|||||||
return date.toISOString().slice(0, 19).replace('T', ' ');
|
return date.toISOString().slice(0, 19).replace('T', ' ');
|
||||||
};
|
};
|
||||||
|
|
||||||
const listOlistProducts = async (changedAfter = null) => {
|
const listOlistProducts = async (changedAfter = null, shouldStop = async () => false) => {
|
||||||
const products = [];
|
const products = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
do {
|
do {
|
||||||
|
if (await shouldStop()) return { products, cancelled: true };
|
||||||
const params = new URLSearchParams({ limit: '100', offset: String(offset) });
|
const params = new URLSearchParams({ limit: '100', offset: String(offset) });
|
||||||
if (changedAfter) params.set('dataAlteracao', formatOlistDate(changedAfter));
|
if (changedAfter) params.set('dataAlteracao', formatOlistDate(changedAfter));
|
||||||
const page = await olistRequest(`/produtos?${params}`);
|
const page = await olistRequest(`/produtos?${params}`);
|
||||||
@@ -302,7 +390,7 @@ const listOlistProducts = async (changedAfter = null) => {
|
|||||||
offset += page?.paginacao?.limit || 100;
|
offset += page?.paginacao?.limit || 100;
|
||||||
if (offset >= Number(page?.paginacao?.total || 0)) break;
|
if (offset >= Number(page?.paginacao?.total || 0)) break;
|
||||||
} while (true);
|
} while (true);
|
||||||
return products;
|
return { products, cancelled: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
const cacheOlistProducts = async (products) => {
|
const cacheOlistProducts = async (products) => {
|
||||||
@@ -400,7 +488,7 @@ const updateRunProgress = async (id, summary) => {
|
|||||||
return mapRun(result.rows[0]);
|
return mapRun(result.rows[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const finishRun = async (id, summary, error = null) => {
|
const finishRun = async (id, summary, { status = 'completed', error = null } = {}) => {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
UPDATE olist_sync_runs
|
UPDATE olist_sync_runs
|
||||||
SET status = $2,
|
SET status = $2,
|
||||||
@@ -416,7 +504,7 @@ const finishRun = async (id, summary, error = null) => {
|
|||||||
details = $11::jsonb
|
details = $11::jsonb
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
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))]);
|
`, [id, status, 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]);
|
return mapRun(result.rows[0]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -428,9 +516,11 @@ const runOlistSync = async ({ trigger = 'manual', fullSync = false } = {}) => {
|
|||||||
const [checkpoint, cacheProgress] = await Promise.all([getCatalogSyncState(), getProductCacheProgress()]);
|
const [checkpoint, cacheProgress] = await Promise.all([getCatalogSyncState(), getProductCacheProgress()]);
|
||||||
const mode = fullSync || !checkpoint || cacheProgress.pending > 0 ? 'full' : 'incremental';
|
const mode = fullSync || !checkpoint || cacheProgress.pending > 0 ? 'full' : 'incremental';
|
||||||
const run = await createRun({ trigger, mode });
|
const run = await createRun({ trigger, mode });
|
||||||
|
activeRunId = run.id;
|
||||||
const summary = { productsScanned: 0, manufacturedProducts: 0, imported: 0, componentCount: 0, referenceCount: 0, skippedReferenceCount: 0, failed: 0, details: [] };
|
const summary = { productsScanned: 0, manufacturedProducts: 0, imported: 0, componentCount: 0, referenceCount: 0, skippedReferenceCount: 0, failed: 0, details: [] };
|
||||||
let lockClient;
|
let lockClient;
|
||||||
try {
|
try {
|
||||||
|
await logRunEvent(run.id, { eventType: 'started', message: 'Sincronização iniciada.' });
|
||||||
lockClient = await pool.connect();
|
lockClient = await pool.connect();
|
||||||
const lock = await lockClient.query('SELECT pg_try_advisory_lock($1) AS locked;', [RUN_LOCK_KEY]);
|
const lock = await lockClient.query('SELECT pg_try_advisory_lock($1) AS locked;', [RUN_LOCK_KEY]);
|
||||||
if (!lock.rows[0]?.locked) {
|
if (!lock.rows[0]?.locked) {
|
||||||
@@ -441,52 +531,118 @@ const runOlistSync = async ({ trigger = 'manual', fullSync = false } = {}) => {
|
|||||||
|
|
||||||
const resumePendingFullScan = mode === 'full' && cacheProgress.total > 0 && cacheProgress.pending > 0;
|
const resumePendingFullScan = mode === 'full' && cacheProgress.total > 0 && cacheProgress.pending > 0;
|
||||||
if (!resumePendingFullScan) {
|
if (!resumePendingFullScan) {
|
||||||
const changedProducts = await listOlistProducts(mode === 'incremental' ? checkpoint.last_catalog_sync_at : null);
|
const catalogResult = await listOlistProducts(
|
||||||
|
mode === 'incremental' ? checkpoint.last_catalog_sync_at : null,
|
||||||
|
async () => stopRequested || isCancellationRequested(run.id)
|
||||||
|
);
|
||||||
|
if (catalogResult.cancelled) {
|
||||||
|
await logRunEvent(run.id, { eventType: 'cancelled', message: 'Sincronização interrompida a pedido do usuário antes de concluir a leitura do catálogo.' });
|
||||||
|
return await finishRun(run.id, summary, { status: 'cancelled' });
|
||||||
|
}
|
||||||
|
const changedProducts = catalogResult.products;
|
||||||
await cacheOlistProducts(changedProducts);
|
await cacheOlistProducts(changedProducts);
|
||||||
await updateCatalogCheckpoint();
|
await updateCatalogCheckpoint();
|
||||||
|
await logRunEvent(run.id, {
|
||||||
|
eventType: 'catalog_loaded',
|
||||||
|
message: `${changedProducts.length.toLocaleString('pt-BR')} produtos recebidos da Olist.`,
|
||||||
|
metadata: { productCount: changedProducts.length, mode }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await logRunEvent(run.id, {
|
||||||
|
eventType: 'resumed',
|
||||||
|
message: 'Retomando estruturas pendentes da sincronização anterior.',
|
||||||
|
metadata: { pendingStructures: cacheProgress.pending }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const [candidates, unitById] = await Promise.all([listStructureCandidates(), getCachedUnitById()]);
|
const [candidates, unitById] = await Promise.all([listStructureCandidates(), getCachedUnitById()]);
|
||||||
for (const product of candidates) {
|
for (const product of candidates) {
|
||||||
|
if (stopRequested || await isCancellationRequested(run.id)) {
|
||||||
|
await logRunEvent(run.id, { eventType: 'cancelled', message: 'Sincronização interrompida a pedido do usuário.' });
|
||||||
|
return await finishRun(run.id, summary, { status: 'cancelled' });
|
||||||
|
}
|
||||||
summary.productsScanned += 1;
|
summary.productsScanned += 1;
|
||||||
try {
|
try {
|
||||||
const structure = await olistRequest(`/produtos/${product.id}/fabricado`);
|
const structure = await olistRequest(`/produtos/${product.id}/fabricado`);
|
||||||
const components = Array.isArray(structure?.produtos) ? structure.produtos : [];
|
const components = Array.isArray(structure?.produtos) ? structure.produtos : [];
|
||||||
if (!components.length) {
|
if (!components.length) {
|
||||||
await markStructureChecked(product.id, false);
|
await markStructureChecked(product.id, false);
|
||||||
continue;
|
await logRunEvent(run.id, {
|
||||||
|
eventType: 'product_skipped',
|
||||||
|
message: `Produto sem composição fabricada: ${product.descricao}.`,
|
||||||
|
productId: product.id,
|
||||||
|
sku: product.sku,
|
||||||
|
productDescription: product.descricao
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
|
await markStructureChecked(product.id, true);
|
||||||
|
await logRunEvent(run.id, {
|
||||||
|
eventType: 'product_imported',
|
||||||
|
message: `Composição importada: ${product.descricao}.`,
|
||||||
|
productId: product.id,
|
||||||
|
sku: product.sku,
|
||||||
|
productDescription: product.descricao,
|
||||||
|
metadata: {
|
||||||
|
componentCount: result.componentCount || 0,
|
||||||
|
referenceCount: result.referenceCount || 0
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
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;
|
|
||||||
await markStructureChecked(product.id, true);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.statusCode === 400 || error.statusCode === 404) {
|
if (error.statusCode === 400 || error.statusCode === 404) {
|
||||||
await markStructureChecked(product.id, false);
|
await markStructureChecked(product.id, false);
|
||||||
|
await logRunEvent(run.id, {
|
||||||
|
eventType: 'product_skipped',
|
||||||
|
message: `Produto sem composição fabricada: ${product.descricao}.`,
|
||||||
|
productId: product.id,
|
||||||
|
sku: product.sku,
|
||||||
|
productDescription: product.descricao
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
summary.failed += 1;
|
summary.failed += 1;
|
||||||
summary.details.push({ productId: product.id, sku: product.sku || '', error: error.message });
|
summary.details.push({ productId: product.id, sku: product.sku || '', error: error.message });
|
||||||
|
await logRunEvent(run.id, {
|
||||||
|
level: 'error',
|
||||||
|
eventType: 'product_failed',
|
||||||
|
message: `Falha ao consultar a composição: ${error.message}`,
|
||||||
|
productId: product.id,
|
||||||
|
sku: product.sku,
|
||||||
|
productDescription: product.descricao,
|
||||||
|
metadata: { error: error.message }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await updateRunProgress(run.id, summary);
|
await updateRunProgress(run.id, summary);
|
||||||
|
if (summary.productsScanned % 25 === 0) {
|
||||||
|
await logRunEvent(run.id, {
|
||||||
|
eventType: 'progress',
|
||||||
|
message: `${summary.productsScanned.toLocaleString('pt-BR')} estruturas verificadas; ${summary.imported.toLocaleString('pt-BR')} composições importadas.`,
|
||||||
|
metadata: { productsScanned: summary.productsScanned, imported: summary.imported, failed: summary.failed }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
await logRunEvent(run.id, { eventType: 'completed', message: 'Sincronização concluída.', metadata: summary });
|
||||||
return await finishRun(run.id, summary);
|
return await finishRun(run.id, summary);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await finishRun(run.id, summary, error);
|
await logRunEvent(run.id, { level: 'error', eventType: 'failed', message: error.message, metadata: { error: error.message } }).catch(() => {});
|
||||||
|
await finishRun(run.id, summary, { status: 'failed', error });
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (lockClient) {
|
if (lockClient) {
|
||||||
@@ -494,7 +650,11 @@ const runOlistSync = async ({ trigger = 'manual', fullSync = false } = {}) => {
|
|||||||
lockClient.release();
|
lockClient.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})().finally(() => { activeSync = null; });
|
})().finally(() => {
|
||||||
|
activeSync = null;
|
||||||
|
activeRunId = null;
|
||||||
|
stopRequested = false;
|
||||||
|
});
|
||||||
|
|
||||||
return activeSync;
|
return activeSync;
|
||||||
};
|
};
|
||||||
@@ -511,6 +671,26 @@ const startOlistSync = async (options) => {
|
|||||||
return { accepted: true };
|
return { accepted: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requestOlistSyncStop = async () => {
|
||||||
|
if (!activeSync) {
|
||||||
|
const error = new Error('Não há uma sincronização Olist em andamento.');
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
stopRequested = true;
|
||||||
|
if (!activeRunId) return { accepted: true, initializing: true };
|
||||||
|
const result = await pool.query(`
|
||||||
|
UPDATE olist_sync_runs
|
||||||
|
SET cancel_requested_at = COALESCE(cancel_requested_at, CURRENT_TIMESTAMP)
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
`, [activeRunId]);
|
||||||
|
if (result.rows[0]) {
|
||||||
|
await logRunEvent(activeRunId, { eventType: 'cancel_requested', message: 'Parada solicitada pelo usuário.' });
|
||||||
|
}
|
||||||
|
return { accepted: true, initializing: false };
|
||||||
|
};
|
||||||
|
|
||||||
const scheduleOlistSync = () => {
|
const scheduleOlistSync = () => {
|
||||||
if (scheduledSync) clearInterval(scheduledSync);
|
if (scheduledSync) clearInterval(scheduledSync);
|
||||||
scheduledSync = setInterval(() => {
|
scheduledSync = setInterval(() => {
|
||||||
@@ -525,9 +705,11 @@ module.exports = {
|
|||||||
completeAuthorization,
|
completeAuthorization,
|
||||||
createAuthorizationUrl,
|
createAuthorizationUrl,
|
||||||
getOlistFrontendRedirect,
|
getOlistFrontendRedirect,
|
||||||
|
getOlistRunDetails,
|
||||||
getOlistStatus,
|
getOlistStatus,
|
||||||
isOlistConfigured,
|
isOlistConfigured,
|
||||||
markInterruptedOlistRuns,
|
markInterruptedOlistRuns,
|
||||||
scheduleOlistSync,
|
scheduleOlistSync,
|
||||||
|
requestOlistSyncStop,
|
||||||
startOlistSync
|
startOlistSync
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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, 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 type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, OlistRunDetails, 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';
|
||||||
@@ -300,6 +300,20 @@ export const startOlistCompositionSync = async (fullSync = false): Promise<void>
|
|||||||
if (!response.ok) throw new Error(data?.error || 'Não foi possível iniciar a sincronização Olist.');
|
if (!response.ok) throw new Error(data?.error || 'Não foi possível iniciar a sincronização Olist.');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const stopOlistCompositionSync = async (): Promise<void> => {
|
||||||
|
const response = await authFetch('/olist/sync/stop', { method: 'POST' });
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) throw new Error(data?.error || 'Não foi possível interromper a sincronização Olist.');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchOlistRunDetails = async (runId: number, eventsPage = 1, productsPage = 1): Promise<OlistRunDetails> => {
|
||||||
|
const params = new URLSearchParams({ eventsPage: String(eventsPage), productsPage: String(productsPage) });
|
||||||
|
const response = await authFetch(`/olist/runs/${runId}?${params}`, { cache: 'no-store' });
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) throw new Error(data?.error || 'Não foi possível consultar os detalhes da execução Olist.');
|
||||||
|
return data as OlistRunDetails;
|
||||||
|
};
|
||||||
|
|
||||||
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,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { AlertTriangle, CheckCircle2, CloudDownload, Database, Link2, Loader2, RefreshCw, Timer, XCircle } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, CloudDownload, Database, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react';
|
||||||
import { fetchOlistStatus, getOlistAuthorizationUrl, startOlistCompositionSync } from '../dataService';
|
import { fetchOlistRunDetails, fetchOlistStatus, getOlistAuthorizationUrl, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService';
|
||||||
import type { OlistStatus, OlistSyncRun } from '../types';
|
import type { OlistRunDetails, OlistStatus, OlistSyncRun } from '../types';
|
||||||
|
|
||||||
const formatDateTime = (value: string | null) => {
|
const formatDateTime = (value: string | null) => {
|
||||||
if (!value) return '—';
|
if (!value) return '—';
|
||||||
@@ -27,15 +27,27 @@ const formatDuration = (startedAt: string | null, endedAt: string | null, now: n
|
|||||||
const runTone = (run: OlistSyncRun) => (
|
const runTone = (run: OlistSyncRun) => (
|
||||||
run.status === 'failed' ? 'text-red-300 border-red-400/30 bg-red-400/10'
|
run.status === 'failed' ? 'text-red-300 border-red-400/30 bg-red-400/10'
|
||||||
: run.status === 'running' ? 'text-sky-300 border-sky-400/30 bg-sky-400/10'
|
: run.status === 'running' ? 'text-sky-300 border-sky-400/30 bg-sky-400/10'
|
||||||
|
: run.status === 'cancelled' ? 'text-amber-200 border-amber-400/30 bg-amber-400/10'
|
||||||
: 'text-emerald-300 border-emerald-400/30 bg-emerald-400/10'
|
: 'text-emerald-300 border-emerald-400/30 bg-emerald-400/10'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const runStatusLabel = (run: OlistSyncRun) => (
|
||||||
|
run.status === 'running' ? (run.cancelRequestedAt ? 'Parando' : 'Em andamento')
|
||||||
|
: run.status === 'failed' ? 'Falhou'
|
||||||
|
: run.status === 'cancelled' ? 'Interrompida'
|
||||||
|
: 'Concluída'
|
||||||
|
);
|
||||||
|
|
||||||
const OlistSync = () => {
|
const OlistSync = () => {
|
||||||
const [status, setStatus] = useState<OlistStatus | null>(null);
|
const [status, setStatus] = useState<OlistStatus | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [now, setNow] = useState(0);
|
const [now, setNow] = useState(0);
|
||||||
|
const [selectedRunId, setSelectedRunId] = useState<number | null>(null);
|
||||||
|
const [runDetails, setRunDetails] = useState<OlistRunDetails | null>(null);
|
||||||
|
const [detailsPages, setDetailsPages] = useState({ events: 1, products: 1 });
|
||||||
|
const [isDetailsLoading, setIsDetailsLoading] = useState(false);
|
||||||
|
|
||||||
const loadStatus = useCallback(async (initial = false) => {
|
const loadStatus = useCallback(async (initial = false) => {
|
||||||
if (initial) setIsLoading(true);
|
if (initial) setIsLoading(true);
|
||||||
@@ -60,6 +72,28 @@ const OlistSync = () => {
|
|||||||
};
|
};
|
||||||
}, [loadStatus]);
|
}, [loadStatus]);
|
||||||
|
|
||||||
|
const loadRunDetails = useCallback(async (runId: number, pages = detailsPages) => {
|
||||||
|
setIsDetailsLoading(true);
|
||||||
|
try {
|
||||||
|
setRunDetails(await fetchOlistRunDetails(runId, pages.events, pages.products));
|
||||||
|
} catch (loadError) {
|
||||||
|
setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar os detalhes da execução.');
|
||||||
|
} finally {
|
||||||
|
setIsDetailsLoading(false);
|
||||||
|
}
|
||||||
|
}, [detailsPages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedRunId) return;
|
||||||
|
void loadRunDetails(selectedRunId);
|
||||||
|
}, [selectedRunId, detailsPages, loadRunDetails]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedRunId || runDetails?.run.status !== 'running') return;
|
||||||
|
const intervalId = window.setInterval(() => void loadRunDetails(selectedRunId), 5_000);
|
||||||
|
return () => window.clearInterval(intervalId);
|
||||||
|
}, [selectedRunId, runDetails?.run.status, loadRunDetails]);
|
||||||
|
|
||||||
const latestRun = status?.runs[0] || null;
|
const latestRun = status?.runs[0] || null;
|
||||||
const completionPercent = useMemo(() => {
|
const completionPercent = useMemo(() => {
|
||||||
const cache = status?.productCache;
|
const cache = status?.productCache;
|
||||||
@@ -89,6 +123,24 @@ const OlistSync = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopSync = async () => {
|
||||||
|
if (!window.confirm('Interromper a sincronização atual? Os produtos já concluídos serão preservados e a próxima sincronização continuará de onde parou.')) return;
|
||||||
|
setIsActionLoading(true);
|
||||||
|
try {
|
||||||
|
await stopOlistCompositionSync();
|
||||||
|
await loadStatus();
|
||||||
|
} catch (actionError) {
|
||||||
|
setError(actionError instanceof Error ? actionError.message : 'Não foi possível interromper a sincronização.');
|
||||||
|
} finally {
|
||||||
|
setIsActionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRunDetails = (runId: number) => {
|
||||||
|
setDetailsPages({ events: 1, products: 1 });
|
||||||
|
setSelectedRunId(runId);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading && !status) {
|
if (isLoading && !status) {
|
||||||
return <div className="flex h-64 items-center justify-center rounded-2xl border border-dark-border bg-dark-card text-brand-primary"><Loader2 className="h-7 w-7 animate-spin" /></div>;
|
return <div className="flex h-64 items-center justify-center rounded-2xl border border-dark-border bg-dark-card text-brand-primary"><Loader2 className="h-7 w-7 animate-spin" /></div>;
|
||||||
}
|
}
|
||||||
@@ -109,9 +161,16 @@ const OlistSync = () => {
|
|||||||
{isActionLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Link2 className="h-4 w-4" />} Conectar Olist
|
{isActionLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Link2 className="h-4 w-4" />} Conectar Olist
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" onClick={() => void startSync()} disabled={isActionLoading || status.syncInProgress} className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">
|
<>
|
||||||
{isActionLoading || status.syncInProgress ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />} {status.syncInProgress ? 'Sincronizando' : 'Sincronizar agora'}
|
<button type="button" onClick={() => void startSync()} disabled={isActionLoading || status.syncInProgress} className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">
|
||||||
</button>
|
{isActionLoading || status.syncInProgress ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />} {status.syncInProgress ? 'Sincronizando' : 'Sincronizar agora'}
|
||||||
|
</button>
|
||||||
|
{status.syncInProgress && (
|
||||||
|
<button type="button" onClick={() => void stopSync()} disabled={isActionLoading || Boolean(latestRun?.cancelRequestedAt)} title="Parar sincronização" aria-label="Parar sincronização" className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-red-400/30 bg-red-400/10 text-red-300 transition-colors hover:bg-red-400/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">
|
||||||
|
<Square className="h-4 w-4 fill-current" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,7 +186,7 @@ const OlistSync = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5">
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5">
|
||||||
<div className="flex items-center justify-between"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Execução atual</span><Timer className="h-4 w-4 text-brand-primary" /></div>
|
<div className="flex items-center justify-between"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Execução atual</span><Timer className="h-4 w-4 text-brand-primary" /></div>
|
||||||
<p className="mt-4 text-lg font-bold text-dark-text">{latestRun?.status === 'running' ? 'Em andamento' : 'Em espera'}</p>
|
<p className="mt-4 text-lg font-bold text-dark-text">{latestRun?.status === 'running' ? (latestRun.cancelRequestedAt ? 'Parando' : 'Em andamento') : 'Em espera'}</p>
|
||||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Duração: {formatDuration(latestRun?.startedAt || null, latestRun?.status === 'running' ? null : latestRun?.completedAt || null, now)}</p>
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Duração: {formatDuration(latestRun?.startedAt || null, latestRun?.status === 'running' ? null : latestRun?.completedAt || null, now)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5">
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5">
|
||||||
@@ -145,8 +204,8 @@ const OlistSync = () => {
|
|||||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Início: {formatDateTime(latestRun.startedAt)} · {latestRun.mode === 'full' ? 'Carga completa' : 'Atualização incremental'}</p>
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Início: {formatDateTime(latestRun.startedAt)} · {latestRun.mode === 'full' ? 'Carga completa' : 'Atualização incremental'}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1 text-xs font-bold ${runTone(latestRun)}`}>
|
<span className={`inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1 text-xs font-bold ${runTone(latestRun)}`}>
|
||||||
{latestRun.status === 'failed' ? <XCircle className="h-3.5 w-3.5" /> : latestRun.status === 'running' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
{latestRun.status === 'failed' ? <XCircle className="h-3.5 w-3.5" /> : latestRun.status === 'running' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : latestRun.status === 'cancelled' ? <Square className="h-3.5 w-3.5 fill-current" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||||
{latestRun.status === 'running' ? 'Em andamento' : latestRun.status === 'failed' ? 'Falhou' : 'Concluída'}
|
{runStatusLabel(latestRun)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-6">
|
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
@@ -161,6 +220,7 @@ const OlistSync = () => {
|
|||||||
</div>
|
</div>
|
||||||
{latestRun.errorMessage && <p className="mt-4 rounded-xl border border-red-400/30 bg-red-400/10 p-3 text-sm font-semibold text-red-300">{latestRun.errorMessage}</p>}
|
{latestRun.errorMessage && <p className="mt-4 rounded-xl border border-red-400/30 bg-red-400/10 p-3 text-sm font-semibold text-red-300">{latestRun.errorMessage}</p>}
|
||||||
{latestRun.details.length > 0 && <div className="mt-4 rounded-xl border border-amber-400/25 bg-amber-400/5 p-3 text-xs font-semibold text-amber-100"><div className="mb-2 flex items-center gap-2"><AlertTriangle className="h-4 w-4" /> Produtos com falha</div>{latestRun.details.slice(0, 5).map((detail, index) => <p key={`${detail.productId || index}-${detail.sku || ''}`}>{detail.sku || detail.productId}: {detail.error || 'Falha sem detalhe'}</p>)}</div>}
|
{latestRun.details.length > 0 && <div className="mt-4 rounded-xl border border-amber-400/25 bg-amber-400/5 p-3 text-xs font-semibold text-amber-100"><div className="mb-2 flex items-center gap-2"><AlertTriangle className="h-4 w-4" /> Produtos com falha</div>{latestRun.details.slice(0, 5).map((detail, index) => <p key={`${detail.productId || index}-${detail.sku || ''}`}>{detail.sku || detail.productId}: {detail.error || 'Falha sem detalhe'}</p>)}</div>}
|
||||||
|
<button type="button" onClick={() => openRunDetails(latestRun.id)} className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg border border-dark-border px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"><ScrollText className="h-4 w-4 text-brand-primary" /> Ver logs e produtos</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -168,9 +228,45 @@ const OlistSync = () => {
|
|||||||
<div className="border-b border-dark-border p-5"><h2 className="text-sm font-bold text-dark-text">Histórico de sincronizações</h2></div>
|
<div className="border-b border-dark-border p-5"><h2 className="text-sm font-bold text-dark-text">Histórico de sincronizações</h2></div>
|
||||||
<div className="divide-y divide-dark-border">
|
<div className="divide-y divide-dark-border">
|
||||||
{!status?.runs.length && <div className="p-8 text-center text-sm font-semibold text-dark-muted">Nenhuma sincronização iniciada.</div>}
|
{!status?.runs.length && <div className="p-8 text-center text-sm font-semibold text-dark-muted">Nenhuma sincronização iniciada.</div>}
|
||||||
{status?.runs.map(run => <div key={run.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between"><div><p className="text-sm font-bold text-dark-text">{run.mode === 'full' ? 'Carga completa' : 'Atualização incremental'} · {run.trigger === 'scheduled' ? 'Automática' : 'Manual'}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatDateTime(run.startedAt)} · duração {formatDuration(run.startedAt, run.status === 'running' ? null : run.completedAt, now)}</p></div><div className="flex flex-wrap items-center gap-3 text-xs font-bold text-dark-muted"><span>{run.imported} comp.</span><span>{run.referenceCount} refs.</span><span className={run.failed ? 'text-red-300' : ''}>{run.failed} falhas</span><span className={`rounded-full border px-2.5 py-1 ${runTone(run)}`}>{run.status === 'running' ? 'Rodando' : run.status === 'failed' ? 'Falhou' : 'Concluída'}</span></div></div>)}
|
{status?.runs.map(run => <div key={run.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between"><div><p className="text-sm font-bold text-dark-text">{run.mode === 'full' ? 'Carga completa' : 'Atualização incremental'} · {run.trigger === 'scheduled' ? 'Automática' : 'Manual'}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatDateTime(run.startedAt)} · duração {formatDuration(run.startedAt, run.status === 'running' ? null : run.completedAt, now)}</p></div><div className="flex flex-wrap items-center gap-3 text-xs font-bold text-dark-muted"><span>{run.imported} comp.</span><span>{run.referenceCount} refs.</span><span className={run.failed ? 'text-red-300' : ''}>{run.failed} falhas</span><span className={`rounded-full border px-2.5 py-1 ${runTone(run)}`}>{runStatusLabel(run)}</span><button type="button" onClick={() => openRunDetails(run.id)} title="Ver logs e produtos afetados" aria-label="Ver logs e produtos afetados" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><ScrollText className="h-4 w-4" /></button></div></div>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedRunId && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-0 backdrop-blur-sm sm:items-center sm:p-6" role="dialog" aria-modal="true" aria-label="Logs e produtos afetados da sincronização Olist">
|
||||||
|
<div className="max-h-[92vh] w-full max-w-6xl overflow-y-auto rounded-t-2xl border border-dark-border bg-dark-card shadow-2xl sm:rounded-2xl">
|
||||||
|
<div className="sticky top-0 z-10 flex items-start justify-between border-b border-dark-border bg-dark-card p-5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-bold text-dark-text">Logs e produtos afetados</h2>
|
||||||
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{runDetails ? `${runDetails.run.mode === 'full' ? 'Carga completa' : 'Atualização incremental'} · ${formatDateTime(runDetails.run.startedAt)}` : 'Carregando execução...'}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => { setSelectedRunId(null); setRunDetails(null); }} title="Fechar" aria-label="Fechar" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer"><X className="h-4 w-4" /></button>
|
||||||
|
</div>
|
||||||
|
{isDetailsLoading && !runDetails ? (
|
||||||
|
<div className="flex h-56 items-center justify-center"><Loader2 className="h-6 w-6 animate-spin text-brand-primary" /></div>
|
||||||
|
) : runDetails ? (
|
||||||
|
<div className="grid gap-5 p-5 lg:grid-cols-2">
|
||||||
|
<section className="overflow-hidden rounded-xl border border-dark-border">
|
||||||
|
<div className="flex items-center justify-between border-b border-dark-border px-4 py-3"><div><h3 className="text-sm font-bold text-dark-text">Logs</h3><p className="mt-0.5 text-xs font-semibold text-dark-muted">{runDetails.events.total.toLocaleString('pt-BR')} eventos</p></div>{isDetailsLoading && <Loader2 className="h-4 w-4 animate-spin text-brand-primary" />}</div>
|
||||||
|
<div className="divide-y divide-dark-border">
|
||||||
|
{!runDetails.events.items.length && <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum evento registrado ainda.</p>}
|
||||||
|
{runDetails.events.items.map(event => <div key={event.id} className="p-4"><div className="flex items-start gap-2"><span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${event.level === 'error' ? 'bg-red-400' : 'bg-sky-400'}`} /><div className="min-w-0"><p className="text-sm font-semibold text-dark-text">{event.message}</p>{event.sku && <p className="mt-1 truncate text-xs font-bold text-brand-primary">{event.sku} · {event.productDescription}</p>}<p className="mt-1 text-[11px] font-semibold text-dark-muted">{formatDateTime(event.createdAt)}</p></div></div></div>)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-dark-border p-3"><button type="button" onClick={() => setDetailsPages(pages => ({ ...pages, events: Math.max(1, pages.events - 1) }))} disabled={runDetails.events.page === 1 || isDetailsLoading} className="text-xs font-bold text-dark-muted disabled:opacity-40 cursor-pointer">Anterior</button><span className="text-xs font-semibold text-dark-muted">Página {runDetails.events.page}</span><button type="button" onClick={() => setDetailsPages(pages => ({ ...pages, events: pages.events + 1 }))} disabled={runDetails.events.page * runDetails.events.pageSize >= runDetails.events.total || isDetailsLoading} className="text-xs font-bold text-brand-primary disabled:opacity-40 cursor-pointer">Próxima</button></div>
|
||||||
|
</section>
|
||||||
|
<section className="overflow-hidden rounded-xl border border-dark-border">
|
||||||
|
<div className="border-b border-dark-border px-4 py-3"><h3 className="text-sm font-bold text-dark-text">Produtos afetados</h3><p className="mt-0.5 text-xs font-semibold text-dark-muted">{runDetails.affectedProducts.total.toLocaleString('pt-BR')} composições importadas nesta execução</p></div>
|
||||||
|
<div className="divide-y divide-dark-border">
|
||||||
|
{!runDetails.affectedProducts.items.length && <p className="p-5 text-sm font-semibold text-dark-muted">Nenhuma composição importada nesta execução.</p>}
|
||||||
|
{runDetails.affectedProducts.items.map(product => <div key={product.id} className="p-4"><p className="text-sm font-bold text-dark-text">{product.productDescription || product.sku || product.productId}</p><p className="mt-1 text-xs font-semibold text-brand-primary">{product.sku || `ID ${product.productId}`}</p><p className="mt-2 text-xs font-semibold text-dark-muted">{Number(product.metadata.componentCount || 0).toLocaleString('pt-BR')} componentes · {Number(product.metadata.referenceCount || 0).toLocaleString('pt-BR')} referências</p></div>)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-dark-border p-3"><button type="button" onClick={() => setDetailsPages(pages => ({ ...pages, products: Math.max(1, pages.products - 1) }))} disabled={runDetails.affectedProducts.page === 1 || isDetailsLoading} className="text-xs font-bold text-dark-muted disabled:opacity-40 cursor-pointer">Anterior</button><span className="text-xs font-semibold text-dark-muted">Página {runDetails.affectedProducts.page}</span><button type="button" onClick={() => setDetailsPages(pages => ({ ...pages, products: pages.products + 1 }))} disabled={runDetails.affectedProducts.page * runDetails.affectedProducts.pageSize >= runDetails.affectedProducts.total || isDetailsLoading} className="text-xs font-bold text-brand-primary disabled:opacity-40 cursor-pointer">Próxima</button></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
) : <div className="p-8 text-center text-sm font-semibold text-dark-muted">Não foi possível carregar os detalhes desta execução.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
21
src/types.ts
21
src/types.ts
@@ -208,9 +208,10 @@ export interface OlistSyncRun {
|
|||||||
id: number;
|
id: number;
|
||||||
trigger: 'manual' | 'scheduled';
|
trigger: 'manual' | 'scheduled';
|
||||||
mode: 'full' | 'incremental';
|
mode: 'full' | 'incremental';
|
||||||
status: 'running' | 'completed' | 'failed';
|
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
|
cancelRequestedAt: string | null;
|
||||||
productsScanned: number;
|
productsScanned: number;
|
||||||
manufacturedProducts: number;
|
manufacturedProducts: number;
|
||||||
imported: number;
|
imported: number;
|
||||||
@@ -222,6 +223,24 @@ export interface OlistSyncRun {
|
|||||||
details: Array<{ productId?: number; sku?: string; error?: string }>;
|
details: Array<{ productId?: number; sku?: string; error?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OlistSyncEvent {
|
||||||
|
id: number;
|
||||||
|
level: 'info' | 'error';
|
||||||
|
eventType: string;
|
||||||
|
message: string;
|
||||||
|
productId: string | null;
|
||||||
|
sku: string;
|
||||||
|
productDescription: string;
|
||||||
|
metadata: Record<string, number | string>;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OlistRunDetails {
|
||||||
|
run: OlistSyncRun;
|
||||||
|
events: { items: OlistSyncEvent[]; total: number; page: number; pageSize: number };
|
||||||
|
affectedProducts: { items: OlistSyncEvent[]; total: number; page: number; pageSize: number };
|
||||||
|
}
|
||||||
|
|
||||||
export interface OlistStatus {
|
export interface OlistStatus {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user