Optimize product detail analytics loading
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
This commit is contained in:
@@ -5,6 +5,7 @@ const {
|
||||
getClientDetailsAnalytics,
|
||||
getDashboardAnalytics,
|
||||
getProductAnalytics,
|
||||
getProductDetailsAnalytics,
|
||||
getRfmAnalytics
|
||||
} = require('../services/analyticsService');
|
||||
|
||||
@@ -33,6 +34,21 @@ router.get('/analytics/products', verifyToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analytics/products/:productId/details', verifyToken, async (req, res) => {
|
||||
try {
|
||||
const details = await getProductDetailsAnalytics(req.params.productId, getRange(req.query));
|
||||
if (!details) {
|
||||
res.status(404).json({ error: 'Product not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(details);
|
||||
} catch (error) {
|
||||
console.error('Error fetching product details analytics:', error);
|
||||
res.status(500).json({ error: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analytics/clients', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await getClientAnalytics(getRange(req.query)));
|
||||
|
||||
@@ -452,6 +452,98 @@ const getProductAnalytics = async (range = {}) => {
|
||||
}));
|
||||
};
|
||||
|
||||
const getProductDetailsAnalytics = async (productId, range = {}) => {
|
||||
const normalizedProductId = String(productId || '').trim();
|
||||
if (!normalizedProductId) return null;
|
||||
|
||||
const normalizedStart = normalizeDateParam(range.start);
|
||||
const normalizedEnd = normalizeDateParam(range.end);
|
||||
const periodParams = [normalizedProductId];
|
||||
const periodFilters = [
|
||||
'produto_id = $1',
|
||||
'data_pedido_date IS NOT NULL'
|
||||
];
|
||||
|
||||
if (normalizedStart) {
|
||||
periodParams.push(normalizedStart);
|
||||
periodFilters.push(`data_pedido_date >= $${periodParams.length}::date`);
|
||||
}
|
||||
|
||||
if (normalizedEnd) {
|
||||
periodParams.push(normalizedEnd);
|
||||
periodFilters.push(`data_pedido_date <= $${periodParams.length}::date`);
|
||||
}
|
||||
|
||||
const [summaryResult, periodResult] = await Promise.all([
|
||||
pool.query(`
|
||||
WITH selected_product AS (
|
||||
SELECT $1::text as id
|
||||
),
|
||||
stock_info AS (
|
||||
SELECT
|
||||
produto_id as id,
|
||||
MAX(NULLIF(nome, '')) as name
|
||||
FROM stock
|
||||
WHERE produto_id = $1
|
||||
GROUP BY produto_id
|
||||
),
|
||||
order_info AS (
|
||||
SELECT
|
||||
produto_id as id,
|
||||
(ARRAY_AGG(COALESCE(NULLIF(produto_descricao, ''), 'Unknown') ORDER BY data_pedido_date DESC NULLS LAST, data_pedido DESC NULLS LAST))[1] as name,
|
||||
(ARRAY_AGG(valor_unitario ORDER BY data_pedido_date DESC NULLS LAST, data_pedido DESC NULLS LAST))[1] as price
|
||||
FROM orders
|
||||
WHERE produto_id = $1
|
||||
GROUP BY produto_id
|
||||
)
|
||||
SELECT
|
||||
selected_product.id,
|
||||
COALESCE(stock_info.name, order_info.name, 'Unknown') as name,
|
||||
COALESCE(order_info.price, 0) as price
|
||||
FROM selected_product
|
||||
LEFT JOIN stock_info ON stock_info.id = selected_product.id
|
||||
LEFT JOIN order_info ON order_info.id = selected_product.id
|
||||
WHERE stock_info.id IS NOT NULL OR order_info.id IS NOT NULL;
|
||||
`, [normalizedProductId]),
|
||||
pool.query(`
|
||||
SELECT
|
||||
data_pedido_date,
|
||||
MAX(data_pedido) as date_label,
|
||||
COALESCE(SUM(quantidade), 0) as quantity_sold,
|
||||
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue
|
||||
FROM orders
|
||||
WHERE ${periodFilters.join(' AND ')}
|
||||
GROUP BY data_pedido_date
|
||||
ORDER BY data_pedido_date ASC;
|
||||
`, periodParams)
|
||||
]);
|
||||
|
||||
const summary = summaryResult.rows[0];
|
||||
if (!summary) return null;
|
||||
|
||||
const chartData = periodResult.rows.map(row => ({
|
||||
date: row.date_label || getDateOnly(row.data_pedido_date) || '',
|
||||
value: toNumber(row.quantity_sold)
|
||||
}));
|
||||
const totalSold = periodResult.rows.reduce((sum, row) => sum + toNumber(row.quantity_sold), 0);
|
||||
const totalRevenue = periodResult.rows.reduce((sum, row) => sum + toNumber(row.revenue), 0);
|
||||
|
||||
return {
|
||||
range: {
|
||||
start: normalizedStart,
|
||||
end: normalizedEnd
|
||||
},
|
||||
productInfo: {
|
||||
id: summary.id,
|
||||
name: summary.name,
|
||||
price: toNumber(summary.price)
|
||||
},
|
||||
chartData,
|
||||
totalSold,
|
||||
totalRevenue
|
||||
};
|
||||
};
|
||||
|
||||
const getClientAnalytics = async (range = {}) => {
|
||||
const { params, whereClause } = buildDateFilter(range);
|
||||
const result = await pool.query(`
|
||||
@@ -837,6 +929,7 @@ module.exports = {
|
||||
getRfmSegment,
|
||||
getClientAnalytics,
|
||||
getDashboardAnalytics,
|
||||
getProductDetailsAnalytics,
|
||||
getProductAnalytics,
|
||||
normalizeDateParam,
|
||||
scoreTertile
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
getClientDetailsAnalytics,
|
||||
getPreviousDate,
|
||||
getProductAnalytics,
|
||||
getProductDetailsAnalytics,
|
||||
getRecencyScore,
|
||||
getRfmAnalytics,
|
||||
getRfmSegment,
|
||||
@@ -392,6 +393,97 @@ test('getProductAnalytics returns exact product rows with stock and latest price
|
||||
}
|
||||
});
|
||||
|
||||
test('getProductDetailsAnalytics returns product identity and period chart without raw order download', async () => {
|
||||
const originalQuery = pool.query;
|
||||
const calls = [];
|
||||
|
||||
pool.query = async (sql, params = []) => {
|
||||
calls.push({ sql, params });
|
||||
|
||||
if (sql.includes('selected_product AS')) {
|
||||
return {
|
||||
rows: [{
|
||||
id: '919483307',
|
||||
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
|
||||
price: 11.9
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
data_pedido_date: '2026-06-01',
|
||||
date_label: '01-06-2026',
|
||||
quantity_sold: 3,
|
||||
revenue: 35.7
|
||||
},
|
||||
{
|
||||
data_pedido_date: '2026-06-02',
|
||||
date_label: '02-06-2026',
|
||||
quantity_sold: 2,
|
||||
revenue: 23.8
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const details = await getProductDetailsAnalytics('919483307', { start: '2026-06-01', end: '2026-06-22' });
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0].sql, /WITH selected_product AS/);
|
||||
assert.match(calls[0].sql, /WHERE produto_id = \$1/);
|
||||
assert.deepEqual(calls[0].params, ['919483307']);
|
||||
assert.match(calls[1].sql, /produto_id = \$1/);
|
||||
assert.match(calls[1].sql, /data_pedido_date >= \$2::date/);
|
||||
assert.match(calls[1].sql, /data_pedido_date <= \$3::date/);
|
||||
assert.deepEqual(calls[1].params, ['919483307', '2026-06-01', '2026-06-22']);
|
||||
assert.deepEqual(details.productInfo, {
|
||||
id: '919483307',
|
||||
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
|
||||
price: 11.9
|
||||
});
|
||||
assert.deepEqual(details.chartData, [
|
||||
{ date: '01-06-2026', value: 3 },
|
||||
{ date: '02-06-2026', value: 2 }
|
||||
]);
|
||||
assert.equal(details.totalSold, 5);
|
||||
assert.equal(details.totalRevenue, 59.5);
|
||||
} finally {
|
||||
pool.query = originalQuery;
|
||||
}
|
||||
});
|
||||
|
||||
test('getProductDetailsAnalytics keeps known products visible with zero period sales', async () => {
|
||||
const originalQuery = pool.query;
|
||||
|
||||
pool.query = async (sql) => {
|
||||
if (sql.includes('selected_product AS')) {
|
||||
return {
|
||||
rows: [{
|
||||
id: 'stock-only',
|
||||
name: 'Produto sem venda no período',
|
||||
price: 0
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
return { rows: [] };
|
||||
};
|
||||
|
||||
try {
|
||||
const details = await getProductDetailsAnalytics('stock-only', { start: '2026-06-01', end: '2026-06-22' });
|
||||
|
||||
assert.equal(details.productInfo.id, 'stock-only');
|
||||
assert.equal(details.totalSold, 0);
|
||||
assert.equal(details.totalRevenue, 0);
|
||||
assert.deepEqual(details.chartData, []);
|
||||
} finally {
|
||||
pool.query = originalQuery;
|
||||
}
|
||||
});
|
||||
|
||||
test('getClientAnalytics returns opaque client tokens', async () => {
|
||||
const originalQuery = pool.query;
|
||||
const calls = [];
|
||||
|
||||
Reference in New Issue
Block a user