feat: add backend analytics endpoints
This commit is contained in:
43
backend/routes/analyticsRoutes.js
Normal file
43
backend/routes/analyticsRoutes.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { verifyToken } = require('../auth');
|
||||||
|
const {
|
||||||
|
getClientAnalytics,
|
||||||
|
getDashboardAnalytics,
|
||||||
|
getProductAnalytics
|
||||||
|
} = require('../services/analyticsService');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const getRange = (query) => ({
|
||||||
|
start: query.start,
|
||||||
|
end: query.end
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/analytics/dashboard', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await getDashboardAnalytics(getRange(req.query)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard analytics:', error);
|
||||||
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/analytics/products', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(await getProductAnalytics(getRange(req.query)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching product 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)));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching client analytics:', error);
|
||||||
|
res.status(500).json({ error: 'Internal Server Error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -6,6 +6,7 @@ const dataRoutes = require('./routes/dataRoutes');
|
|||||||
const stockRoutes = require('./routes/stockRoutes');
|
const stockRoutes = require('./routes/stockRoutes');
|
||||||
const campaignRoutes = require('./routes/campaignRoutes');
|
const campaignRoutes = require('./routes/campaignRoutes');
|
||||||
const internalRoutes = require('./routes/internalRoutes');
|
const internalRoutes = require('./routes/internalRoutes');
|
||||||
|
const analyticsRoutes = require('./routes/analyticsRoutes');
|
||||||
|
|
||||||
const createApp = () => {
|
const createApp = () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -17,6 +18,7 @@ const createApp = () => {
|
|||||||
app.use('/api', dataRoutes);
|
app.use('/api', dataRoutes);
|
||||||
app.use('/api', stockRoutes);
|
app.use('/api', stockRoutes);
|
||||||
app.use('/api', campaignRoutes);
|
app.use('/api', campaignRoutes);
|
||||||
|
app.use('/api', analyticsRoutes);
|
||||||
app.use('/api/internal', internalRoutes);
|
app.use('/api/internal', internalRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
177
backend/services/analyticsService.js
Normal file
177
backend/services/analyticsService.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
const { pool } = require('../db');
|
||||||
|
|
||||||
|
const PRODUCT_NAME_SQL = "NULLIF(TRIM(split_part(COALESCE(produto_descricao, 'Unknown'), ' TAMANHO', 1)), '')";
|
||||||
|
|
||||||
|
const normalizeDateParam = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const match = String(value).trim().match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [, yearValue, monthValue, dayValue] = match;
|
||||||
|
const year = Number(yearValue);
|
||||||
|
const month = Number(monthValue);
|
||||||
|
const day = Number(dayValue);
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
|
||||||
|
if (
|
||||||
|
date.getUTCFullYear() !== year ||
|
||||||
|
date.getUTCMonth() !== month - 1 ||
|
||||||
|
date.getUTCDate() !== day
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${yearValue}-${monthValue}-${dayValue}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildDateFilter = ({ start, end } = {}) => {
|
||||||
|
const params = [];
|
||||||
|
const filters = ['data_pedido_date IS NOT NULL'];
|
||||||
|
const normalizedStart = normalizeDateParam(start);
|
||||||
|
const normalizedEnd = normalizeDateParam(end);
|
||||||
|
|
||||||
|
if (normalizedStart) {
|
||||||
|
params.push(normalizedStart);
|
||||||
|
filters.push(`data_pedido_date >= $${params.length}::date`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedEnd) {
|
||||||
|
params.push(normalizedEnd);
|
||||||
|
filters.push(`data_pedido_date <= $${params.length}::date`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
params,
|
||||||
|
whereClause: `WHERE ${filters.join(' AND ')}`
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNumber = (value) => Number(value || 0);
|
||||||
|
|
||||||
|
const getDashboardAnalytics = async (range = {}) => {
|
||||||
|
const { params, whereClause } = buildDateFilter(range);
|
||||||
|
const [totalsResult, salesResult, revenueResult] = await Promise.all([
|
||||||
|
pool.query(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(quantidade * valor_unitario), 0) as total_revenue,
|
||||||
|
COALESCE(SUM(quantidade), 0) as total_items,
|
||||||
|
COUNT(*)::int as order_line_count
|
||||||
|
FROM orders
|
||||||
|
${whereClause};
|
||||||
|
`, params),
|
||||||
|
pool.query(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name,
|
||||||
|
MAX(produto_id) as id,
|
||||||
|
COALESCE(SUM(quantidade), 0) as value
|
||||||
|
FROM orders
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY name
|
||||||
|
ORDER BY value DESC
|
||||||
|
LIMIT 10;
|
||||||
|
`, params),
|
||||||
|
pool.query(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name,
|
||||||
|
MAX(produto_id) as id,
|
||||||
|
COALESCE(SUM(quantidade * valor_unitario), 0) as value
|
||||||
|
FROM orders
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY name
|
||||||
|
ORDER BY value DESC
|
||||||
|
LIMIT 10;
|
||||||
|
`, params)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totals = totalsResult.rows[0] || {};
|
||||||
|
const orderLineCount = toNumber(totals.order_line_count);
|
||||||
|
const totalRevenue = toNumber(totals.total_revenue);
|
||||||
|
|
||||||
|
return {
|
||||||
|
range: {
|
||||||
|
start: normalizeDateParam(range.start),
|
||||||
|
end: normalizeDateParam(range.end)
|
||||||
|
},
|
||||||
|
totalRevenue,
|
||||||
|
totalOrders: toNumber(totals.total_items),
|
||||||
|
orderLineCount,
|
||||||
|
averageOrderValue: orderLineCount ? totalRevenue / orderLineCount : 0,
|
||||||
|
salesByProduct: salesResult.rows.map(row => ({
|
||||||
|
name: row.name,
|
||||||
|
id: row.id,
|
||||||
|
value: toNumber(row.value)
|
||||||
|
})),
|
||||||
|
revenueByProduct: revenueResult.rows.map(row => ({
|
||||||
|
name: row.name,
|
||||||
|
id: row.id,
|
||||||
|
value: toNumber(row.value)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProductAnalytics = async (range = {}) => {
|
||||||
|
const { params, whereClause } = buildDateFilter(range);
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name,
|
||||||
|
MAX(produto_id) as id,
|
||||||
|
COALESCE(SUM(quantidade), 0) as quantity_sold,
|
||||||
|
COALESCE(SUM(quantidade * valor_unitario), 0) as revenue,
|
||||||
|
COUNT(*)::int as order_line_count,
|
||||||
|
MIN(data_pedido_date) as first_sale_date,
|
||||||
|
MAX(data_pedido_date) as last_sale_date
|
||||||
|
FROM orders
|
||||||
|
${whereClause}
|
||||||
|
GROUP BY name
|
||||||
|
ORDER BY revenue DESC, quantity_sold DESC
|
||||||
|
LIMIT 500;
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
return result.rows.map(row => ({
|
||||||
|
name: row.name,
|
||||||
|
id: row.id,
|
||||||
|
quantitySold: toNumber(row.quantity_sold),
|
||||||
|
revenue: toNumber(row.revenue),
|
||||||
|
orderLineCount: toNumber(row.order_line_count),
|
||||||
|
firstSaleDate: row.first_sale_date,
|
||||||
|
lastSaleDate: row.last_sale_date
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClientAnalytics = async (range = {}) => {
|
||||||
|
const { params, whereClause } = buildDateFilter(range);
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
MAX(cliente_nome) as name,
|
||||||
|
cliente_fone as phone,
|
||||||
|
COALESCE(SUM(quantidade), 0) as quantity_purchased,
|
||||||
|
COALESCE(SUM(quantidade * valor_unitario), 0) as total_spent,
|
||||||
|
COUNT(*)::int as order_line_count,
|
||||||
|
MAX(data_pedido_date) as last_purchase_date
|
||||||
|
FROM orders
|
||||||
|
${whereClause}
|
||||||
|
AND cliente_fone IS NOT NULL
|
||||||
|
AND cliente_fone != ''
|
||||||
|
GROUP BY cliente_fone
|
||||||
|
ORDER BY total_spent DESC
|
||||||
|
LIMIT 500;
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
return result.rows.map(row => ({
|
||||||
|
name: row.name,
|
||||||
|
phone: row.phone,
|
||||||
|
quantityPurchased: toNumber(row.quantity_purchased),
|
||||||
|
totalSpent: toNumber(row.total_spent),
|
||||||
|
orderLineCount: toNumber(row.order_line_count),
|
||||||
|
lastPurchaseDate: row.last_purchase_date
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildDateFilter,
|
||||||
|
getClientAnalytics,
|
||||||
|
getDashboardAnalytics,
|
||||||
|
getProductAnalytics,
|
||||||
|
normalizeDateParam
|
||||||
|
};
|
||||||
34
backend/test/analyticsService.test.js
Normal file
34
backend/test/analyticsService.test.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const { buildDateFilter, normalizeDateParam } = require('../services/analyticsService');
|
||||||
|
|
||||||
|
test('normalizeDateParam accepts strict ISO dates', () => {
|
||||||
|
assert.equal(normalizeDateParam('2026-05-28'), '2026-05-28');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeDateParam rejects non-ISO or impossible dates', () => {
|
||||||
|
assert.equal(normalizeDateParam('28/05/2026'), null);
|
||||||
|
assert.equal(normalizeDateParam('2026-02-31'), null);
|
||||||
|
assert.equal(normalizeDateParam(''), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDateFilter builds bounded date predicates', () => {
|
||||||
|
const filter = buildDateFilter({ start: '2026-05-01', end: '2026-05-28' });
|
||||||
|
|
||||||
|
assert.deepEqual(filter.params, ['2026-05-01', '2026-05-28']);
|
||||||
|
assert.equal(
|
||||||
|
filter.whereClause,
|
||||||
|
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date >= $1::date AND data_pedido_date <= $2::date'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildDateFilter ignores invalid bounds', () => {
|
||||||
|
const filter = buildDateFilter({ start: 'invalid', end: '2026-05-28' });
|
||||||
|
|
||||||
|
assert.deepEqual(filter.params, ['2026-05-28']);
|
||||||
|
assert.equal(
|
||||||
|
filter.whereClause,
|
||||||
|
'WHERE data_pedido_date IS NOT NULL AND data_pedido_date <= $1::date'
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user