feat: add backend analytics endpoints
This commit is contained in:
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
|
||||
};
|
||||
Reference in New Issue
Block a user