diff --git a/backend/routes/analyticsRoutes.js b/backend/routes/analyticsRoutes.js new file mode 100644 index 0000000..fc1d9cb --- /dev/null +++ b/backend/routes/analyticsRoutes.js @@ -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; diff --git a/backend/server.js b/backend/server.js index d1899eb..a4092a1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -6,6 +6,7 @@ const dataRoutes = require('./routes/dataRoutes'); const stockRoutes = require('./routes/stockRoutes'); const campaignRoutes = require('./routes/campaignRoutes'); const internalRoutes = require('./routes/internalRoutes'); +const analyticsRoutes = require('./routes/analyticsRoutes'); const createApp = () => { const app = express(); @@ -17,6 +18,7 @@ const createApp = () => { app.use('/api', dataRoutes); app.use('/api', stockRoutes); app.use('/api', campaignRoutes); + app.use('/api', analyticsRoutes); app.use('/api/internal', internalRoutes); return app; diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js new file mode 100644 index 0000000..470a1f9 --- /dev/null +++ b/backend/services/analyticsService.js @@ -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 +}; diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js new file mode 100644 index 0000000..1163291 --- /dev/null +++ b/backend/test/analyticsService.test.js @@ -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' + ); +});