const { pool } = require('../db'); const SIZE_SUFFIX_SQL_PATTERN = '\\s+-\\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2})(?:/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2}))*)$'; const PRODUCT_NAME_SQL = ` CASE WHEN COALESCE(produto_descricao, 'Unknown') ILIKE 'ETIQUETA%' THEN COALESCE(produto_descricao, 'Unknown') ELSE NULLIF(TRIM(regexp_replace(split_part(COALESCE(produto_descricao, 'Unknown'), ' TAMANHO', 1), '${SIZE_SUFFIX_SQL_PATTERN}', '', 'i')), '') END `; const CUSTOMER_KEY_SQL = "COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido'))"; 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 getPreviousDate = (value) => { const normalizedDate = normalizeDateParam(value); if (!normalizedDate) return null; const date = new Date(`${normalizedDate}T00:00:00.000Z`); date.setUTCDate(date.getUTCDate() - 1); return date.toISOString().slice(0, 10); }; const toNumber = (value) => Number(value || 0); const RFM_SEGMENTS = { '3-3': { key: 'champions', label: 'Champions' }, '3-2': { key: 'potential_loyalists', label: 'Potenciais Leais' }, '3-1': { key: 'new_customers', label: 'Novos Clientes' }, '2-3': { key: 'loyal_customers', label: 'Clientes Leais' }, '2-2': { key: 'need_attention', label: 'Precisam de Atenção' }, '2-1': { key: 'about_to_sleep', label: 'Quase Dormindo' }, '1-3': { key: 'at_risk', label: 'Em Risco' }, '1-2': { key: 'hibernating', label: 'Hibernando' }, '1-1': { key: 'lost', label: 'Perdidos' } }; const scoreTertile = (value, values, higherIsBetter = true) => { const numericValues = values.map(toNumber).filter(Number.isFinite); if (!numericValues.length) return 1; if (numericValues.length === 1) return 3; const min = Math.min(...numericValues); const max = Math.max(...numericValues); if (min === max) return higherIsBetter ? 2 : 3; const sorted = [...numericValues].sort((a, b) => higherIsBetter ? a - b : b - a); const index = sorted.findIndex(candidate => candidate === toNumber(value)); const percentile = index / (sorted.length - 1); return Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1)); }; const getRfmSegment = (recencyScore, valueScore) => { return RFM_SEGMENTS[`${recencyScore}-${valueScore}`] || RFM_SEGMENTS['1-1']; }; const buildRfmSegments = (clients) => { return Object.values(RFM_SEGMENTS).map(segment => { const segmentClients = clients.filter(client => client.segmentKey === segment.key); const totalRevenue = segmentClients.reduce((sum, client) => sum + client.monetary, 0); return { ...segment, count: segmentClients.length, totalRevenue, averageRevenue: segmentClients.length ? totalRevenue / segmentClients.length : 0 }; }); }; const buildRfmClients = (baseClients) => { const recencyValues = baseClients.map(client => client.recencyDays); const frequencyValues = baseClients.map(client => client.rfmFrequency ?? client.frequency); const monetaryValues = baseClients.map(client => client.rfmMonetary ?? client.monetary); return baseClients.map(client => { const frequencyForScore = client.rfmFrequency ?? client.frequency; const monetaryForScore = client.rfmMonetary ?? client.monetary; const recencyScore = scoreTertile(client.recencyDays, recencyValues, false); const frequencyScore = scoreTertile(frequencyForScore, frequencyValues, true); const monetaryScore = scoreTertile(monetaryForScore, monetaryValues, true); const valueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2))); const segment = getRfmSegment(recencyScore, valueScore); return { ...client, recencyScore, frequencyScore, monetaryScore, valueScore, rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`, segmentKey: segment.key, segmentLabel: segment.label }; }).sort((a, b) => { if (b.recencyScore !== a.recencyScore) return b.recencyScore - a.recencyScore; if (b.valueScore !== a.valueScore) return b.valueScore - a.valueScore; return b.monetary - a.monetary; }); }; 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 ${CUSTOMER_KEY_SQL} as customer_key, MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, MAX(NULLIF(cliente_fone, '')) as phone, COALESCE(SUM(quantidade), 0) as quantity_purchased, COALESCE(SUM(quantidade * valor_unitario), 0) as total_spent, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count, MAX(data_pedido_date) as last_purchase_date FROM orders ${whereClause} GROUP BY customer_key ORDER BY total_spent DESC; `, params); return result.rows.map(row => ({ customerKey: row.customer_key, name: row.name, phone: row.phone || '', quantityPurchased: toNumber(row.quantity_purchased), totalSpent: toNumber(row.total_spent), orderCount: toNumber(row.order_count), lastPurchaseDate: row.last_purchase_date })); }; const getRfmAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); const normalizedStart = normalizeDateParam(range.start); const normalizedEnd = normalizeDateParam(range.end); const tagReference = getPreviousDate(normalizedStart) || normalizedEnd; const recencyReferenceDate = tagReference ? '$1::date' : 'CURRENT_DATE'; const historyParams = tagReference ? [tagReference] : []; const [periodResult, historyResult] = await Promise.all([ pool.query(` SELECT ${CUSTOMER_KEY_SQL} as customer_key, MAX(cliente_nome) as name, MAX(NULLIF(cliente_fone, '')) as phone, COALESCE(SUM(quantidade * valor_unitario), 0) as monetary, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency, COALESCE(SUM(quantidade), 0) as quantity_purchased, MAX(data_pedido_date) as last_purchase_date FROM orders ${whereClause} GROUP BY customer_key ORDER BY monetary DESC; `, params), pool.query(` SELECT ${CUSTOMER_KEY_SQL} as customer_key, MAX(cliente_nome) as name, MAX(NULLIF(cliente_fone, '')) as phone, COALESCE(SUM(quantidade * valor_unitario), 0) as monetary, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency, COALESCE(SUM(quantidade), 0) as quantity_purchased, MAX(data_pedido_date) as last_purchase_date, GREATEST((${recencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days FROM orders WHERE data_pedido_date IS NOT NULL AND data_pedido_date <= ${recencyReferenceDate} GROUP BY customer_key; `, historyParams) ]); const historyClients = buildRfmClients(historyResult.rows.map(row => ({ customerKey: row.customer_key, name: row.name, phone: row.phone || '', monetary: toNumber(row.monetary), frequency: toNumber(row.frequency), quantityPurchased: toNumber(row.quantity_purchased), lastPurchaseDate: row.last_purchase_date, recencyDays: toNumber(row.recency_days) }))); const tagsByCustomerKey = new Map(historyClients.map(client => [client.customerKey, client])); const clients = periodResult.rows.map(row => { const taggedClient = tagsByCustomerKey.get(row.customer_key); if (!taggedClient) { const newCustomerSegment = getRfmSegment(3, 1); return { customerKey: row.customer_key, name: row.name, phone: row.phone || '', monetary: toNumber(row.monetary), frequency: toNumber(row.frequency), quantityPurchased: toNumber(row.quantity_purchased), lastPurchaseDate: row.last_purchase_date, recencyDays: 0, recencyScore: 3, frequencyScore: 1, monetaryScore: 1, valueScore: 1, rfmScore: '311', segmentKey: newCustomerSegment.key, segmentLabel: newCustomerSegment.label }; } return { ...taggedClient, customerKey: row.customer_key, name: row.name, phone: row.phone || '', monetary: toNumber(row.monetary), frequency: toNumber(row.frequency), quantityPurchased: toNumber(row.quantity_purchased), lastPurchaseDate: row.last_purchase_date }; }).sort((a, b) => { if (b.recencyScore !== a.recencyScore) return b.recencyScore - a.recencyScore; if (b.valueScore !== a.valueScore) return b.valueScore - a.valueScore; return b.monetary - a.monetary; }); return { range: { start: normalizeDateParam(range.start), end: normalizeDateParam(range.end) }, clients, segments: buildRfmSegments(clients), matrix: { recencyScores: [3, 2, 1], valueScores: [1, 2, 3] } }; }; module.exports = { buildDateFilter, buildRfmClients, buildRfmSegments, getPreviousDate, getRfmAnalytics, getRfmSegment, getClientAnalytics, getDashboardAnalytics, getProductAnalytics, normalizeDateParam, scoreTertile };