const crypto = require('node:crypto'); const { pool } = require('../db'); const RFM_QUERY_TIMEOUT_MS = 15000; const RECENT_MAX_DAYS = 60; const COOLING_MAX_DAYS = 180; const LOST_MIN_DAYS = 366; const FREQUENCY_MEDIUM_MAX_ORDERS = 4; const CLIENT_TOKEN_VERSION = 'v1'; 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 getClientTokenSecret = () => ( process.env.CLIENT_TOKEN_SECRET || process.env.JWT_SECRET || process.env.API_KEY || 'nexstar-client-token-development-secret' ); let clientTokenSecretCache = null; const getClientTokenHashSecret = () => { const secret = getClientTokenSecret(); if (clientTokenSecretCache?.secret === secret) return clientTokenSecretCache.hashSecret; clientTokenSecretCache = { secret, hashSecret: crypto.createHash('sha256').update(`${secret}:client-token`).digest('base64url') }; return clientTokenSecretCache.hashSecret; }; const createClientToken = (customerKey) => { if (!customerKey) return ''; const normalizedCustomerKey = String(customerKey); const digest = crypto .createHash('sha256') .update(getClientTokenHashSecret()) .update(':') .update(normalizedCustomerKey) .digest('base64url'); return `${CLIENT_TOKEN_VERSION}.${digest}`; }; const isClientToken = (clientToken) => { const parts = String(clientToken || '').split('.'); return parts.length === 2 && parts[0] === CLIENT_TOKEN_VERSION && /^[A-Za-z0-9_-]+$/.test(parts[1] || ''); }; const persistClientTokenMappings = async (clients, queryable = pool) => { const mappingsByCustomerKey = new Map(); clients.forEach(client => { const customerKey = client.customerKey || client.customer_key; const clientToken = client.clientToken || createClientToken(customerKey); if (customerKey && clientToken) { mappingsByCustomerKey.set(customerKey, clientToken); } }); const mappings = [...mappingsByCustomerKey.entries()]; const chunkSize = 5000; for (let index = 0; index < mappings.length; index += chunkSize) { const chunk = mappings.slice(index, index + chunkSize); const params = []; const values = chunk.map(([customerKey, clientToken], chunkIndex) => { params.push(customerKey, clientToken); const offset = chunkIndex * 2; return `($${offset + 1}, $${offset + 2})`; }); await queryable.query(` INSERT INTO client_identity_tokens (customer_key, token) VALUES ${values.join(', ')} ON CONFLICT (customer_key) DO UPDATE SET token = EXCLUDED.token, updated_at = NOW() WHERE client_identity_tokens.token IS DISTINCT FROM EXCLUDED.token; `, params); } }; const resolveClientToken = async (clientToken) => { if (!isClientToken(clientToken)) return null; const result = await pool.query(` SELECT customer_key FROM client_identity_tokens WHERE token = $1 LIMIT 1; `, [clientToken]); return result.rows[0]?.customer_key || null; }; 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 RFM_SEGMENTS_BY_KEY = new Map( Object.values(RFM_SEGMENTS).map(segment => [segment.key, segment]) ); 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 buildTertileScorer = (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 scoresByValue = new Map(); sorted.forEach((candidate, index) => { if (scoresByValue.has(candidate)) return; const percentile = index / (sorted.length - 1); scoresByValue.set(candidate, Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1))); }); return (value) => scoresByValue.get(toNumber(value)) || 1; }; const getRfmSegment = (recencyScore, valueScore) => { return RFM_SEGMENTS[`${recencyScore}-${valueScore}`] || RFM_SEGMENTS['1-1']; }; const getRecencyScore = (recencyDays) => { const days = Math.max(0, toNumber(recencyDays)); if (days <= RECENT_MAX_DAYS) return 3; if (days <= COOLING_MAX_DAYS) return 2; return 1; }; const getFrequencyScore = (frequency) => { const orders = Math.max(0, toNumber(frequency)); if (orders <= 1) return 1; if (orders <= FREQUENCY_MEDIUM_MAX_ORDERS) return 2; return 3; }; const getLifecycleSegment = ({ recencyDays, historicalFrequency, frequencyScore, monetaryScore, valueScore }) => { const days = Math.max(0, toNumber(recencyDays)); if (days >= LOST_MIN_DAYS) { return { segment: RFM_SEGMENTS_BY_KEY.get('lost'), valueScore: 1 }; } if (days > COOLING_MAX_DAYS) { const hasStrongHistory = frequencyScore === 3 || monetaryScore === 3; return hasStrongHistory ? { segment: RFM_SEGMENTS_BY_KEY.get('at_risk'), valueScore: 3 } : { segment: RFM_SEGMENTS_BY_KEY.get('hibernating'), valueScore: 2 }; } if (days <= RECENT_MAX_DAYS && historicalFrequency === 1) { return { segment: RFM_SEGMENTS_BY_KEY.get('new_customers'), valueScore: 1 }; } if (days <= RECENT_MAX_DAYS) { const isChampion = frequencyScore === 3 && monetaryScore === 3; return isChampion ? { segment: RFM_SEGMENTS_BY_KEY.get('champions'), valueScore: 3 } : { segment: RFM_SEGMENTS_BY_KEY.get('potential_loyalists'), valueScore: 2 }; } return { segment: getRfmSegment(getRecencyScore(days), valueScore), valueScore }; }; const getDateDiffDays = (endDate, startDate) => { const normalizedEnd = normalizeDateParam(endDate); const normalizedStart = normalizeDateParam(startDate); if (!normalizedEnd || !normalizedStart) return 0; const end = new Date(`${normalizedEnd}T00:00:00.000Z`); const start = new Date(`${normalizedStart}T00:00:00.000Z`); return Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86400000)); }; const getDateOnly = (value) => { if (!value) return null; if (value instanceof Date) return value.toISOString().slice(0, 10); const match = String(value).match(/^(\d{4}-\d{2}-\d{2})/); return match ? match[1] : null; }; 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 monetaryScoreFor = buildTertileScorer(baseClients.map(client => client.rfmMonetary ?? client.monetary), true); return baseClients.map(client => { const recencyDays = Math.max(0, toNumber(client.recencyDays)); const historicalFrequency = toNumber(client.rfmFrequency ?? client.frequency); const historicalMonetary = toNumber(client.rfmMonetary ?? client.monetary); const recencyScore = getRecencyScore(recencyDays); const frequencyScore = client.rfmFrequencyScore ?? getFrequencyScore(historicalFrequency); const monetaryScore = client.rfmMonetaryScore ?? monetaryScoreFor(historicalMonetary); const baseValueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2))); const classification = getLifecycleSegment({ recencyDays, historicalFrequency, frequencyScore, monetaryScore, valueScore: baseValueScore }); const valueScore = classification.valueScore; const segment = classification.segment; return { ...client, recencyDays, 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); const clients = result.rows.map(row => ({ customerKey: row.customer_key, clientToken: createClientToken(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 })); await persistClientTokenMappings(clients); return clients; }; const getOrderGroupKey = (row) => ( row.pedido_id || `${row.data_pedido || getDateOnly(row.data_pedido_date) || ''}_${row.valor_pedido || 0}` ); const getClientDetailsAnalytics = async (clientToken, range = {}) => { const customerKey = await resolveClientToken(clientToken); if (!customerKey) return null; const normalizedStart = normalizeDateParam(range.start); const normalizedEnd = normalizeDateParam(range.end); const periodParams = [customerKey]; const periodFilters = [ `${CUSTOMER_KEY_SQL} = $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(` SELECT MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, MAX(NULLIF(cliente_fone, '')) as phone, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as all_time_order_count FROM orders WHERE ${CUSTOMER_KEY_SQL} = $1 AND data_pedido_date IS NOT NULL; `, [customerKey]), pool.query(` SELECT cliente_nome, cliente_fone, data_pedido, data_pedido_date, valor_pedido, produto_id, produto_descricao, quantidade, valor_unitario, pedido_id, cliente_nome_fantasia, id_vendedor, nome_vendedor, marketplace, canal_venda, numero_ecommerce FROM orders WHERE ${periodFilters.join(' AND ')} ORDER BY data_pedido_date DESC, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) DESC; `, periodParams) ]); const summary = summaryResult.rows[0] || {}; const allTimeOrderCount = toNumber(summary.all_time_order_count); if (!allTimeOrderCount) return null; const groupedOrdersByKey = new Map(); const spentByDate = new Map(); let periodSpent = 0; let periodItems = 0; periodResult.rows.forEach(row => { const itemRevenue = toNumber(row.quantidade) * toNumber(row.valor_unitario); const dateKey = getDateOnly(row.data_pedido_date) || getDateOnly(row.data_pedido) || ''; const dateLabel = row.data_pedido || dateKey; const groupKey = getOrderGroupKey(row); periodSpent += itemRevenue; periodItems += toNumber(row.quantidade); if (dateLabel) { const currentDateSpend = spentByDate.get(dateLabel) || { date: dateLabel, sortDate: dateKey, value: 0 }; currentDateSpend.value += itemRevenue; spentByDate.set(dateLabel, currentDateSpend); } if (!groupedOrdersByKey.has(groupKey)) { groupedOrdersByKey.set(groupKey, { date: dateLabel, sortDate: dateKey, orderId: row.pedido_id || groupKey, orderTotal: 0, items: [] }); } const group = groupedOrdersByKey.get(groupKey); group.orderTotal += itemRevenue; group.items.push({ Nome_Cliente: row.cliente_nome || summary.name || 'Cliente Desconhecido', Data_Pedido: dateLabel, Valor_Pedido: toNumber(row.valor_pedido), ID_Produto: row.produto_id || '', Descricao_Produto: row.produto_descricao || 'Unknown', Quantidade: toNumber(row.quantidade), Valor_Unitario: toNumber(row.valor_unitario), ID_Pedido: row.pedido_id || '', Fone_Cliente: row.cliente_fone || '', cliente_nome_fantasia: row.cliente_nome_fantasia || '', id_vendedor: row.id_vendedor || '', nome_vendedor: row.nome_vendedor || '', marketplace: row.marketplace || '', canal_venda: row.canal_venda || '', numero_ecommerce: row.numero_ecommerce || '' }); }); const groupedOrders = [...groupedOrdersByKey.values()] .sort((a, b) => String(b.sortDate).localeCompare(String(a.sortDate))) .map(({ sortDate, ...group }) => group); const chartData = [...spentByDate.values()] .sort((a, b) => String(a.sortDate).localeCompare(String(b.sortDate))) .map(({ sortDate, ...entry }) => entry); const periodOrderCount = groupedOrders.length; return { range: { start: normalizedStart, end: normalizedEnd }, clientToken, clientName: summary.name || 'Cliente Desconhecido', clientPhone: summary.phone || '', hasClient: true, allTimeOrderCount, periodSpent, periodAverageTicket: periodOrderCount ? periodSpent / periodOrderCount : 0, periodOrderCount, periodItems, chartData, groupedOrders }; }; const getRfmAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); const normalizedStart = normalizeDateParam(range.start); const normalizedEnd = normalizeDateParam(range.end); const periodRecencyReferenceDate = normalizedEnd ? `$${params.length}::date` : 'CURRENT_DATE'; const usePeriodAsHistory = !normalizedStart || normalizedStart <= '2000-01-01'; if (usePeriodAsHistory) { const clientRows = await getClientAnalytics({ end: normalizedEnd }); const recencyEnd = normalizedEnd || new Date().toISOString().slice(0, 10); const clients = buildRfmClients(clientRows.map(row => ({ customerKey: row.customerKey, clientToken: row.clientToken || createClientToken(row.customerKey), name: row.name, phone: row.phone || '', monetary: row.totalSpent, frequency: row.orderCount, quantityPurchased: row.quantityPurchased, lastPurchaseDate: row.lastPurchaseDate, recencyDays: getDateDiffDays(recencyEnd, getDateOnly(row.lastPurchaseDate)) }))); return { range: { start: normalizeDateParam(range.start), end: normalizeDateParam(range.end) }, clients, segments: buildRfmSegments(clients), matrix: { recencyScores: [3, 2, 1], valueScores: [1, 2, 3] } }; } const client = await pool.connect(); try { await client.query('BEGIN'); await client.query(`SET LOCAL statement_timeout = '${RFM_QUERY_TIMEOUT_MS}ms'`); const periodResult = await client.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 * 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((${periodRecencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days FROM orders ${whereClause} GROUP BY customer_key ORDER BY monetary DESC; `, params); if (!periodResult.rows.length) { await client.query('COMMIT'); return { range: { start: normalizeDateParam(range.start), end: normalizeDateParam(range.end) }, clients: [], segments: buildRfmSegments([]), matrix: { recencyScores: [3, 2, 1], valueScores: [1, 2, 3] } }; } let historyRows = periodResult.rows; if (!usePeriodAsHistory) { const periodCustomerKeys = [...new Set(periodResult.rows .map(row => row.customer_key) .filter(Boolean))]; const historyParams = []; const recencyReferenceDate = normalizedEnd ? `$${historyParams.push(normalizedEnd)}::date` : 'CURRENT_DATE'; const customerKeysParam = `$${historyParams.push(periodCustomerKeys)}::text[]`; const historyResult = await client.query(` WITH customer_history AS ( 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 * 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 ), scored_history AS MATERIALIZED ( SELECT customer_history.*, CASE WHEN frequency <= 1 THEN 1 WHEN frequency <= ${FREQUENCY_MEDIUM_MAX_ORDERS} THEN 2 ELSE 3 END as frequency_score, CASE WHEN COUNT(*) OVER () = 1 THEN 3 WHEN MIN(monetary) OVER () = MAX(monetary) OVER () THEN 2 ELSE LEAST(3, GREATEST(1, FLOOR(PERCENT_RANK() OVER (ORDER BY monetary) * 3)::int + 1)) END as monetary_score FROM customer_history ) SELECT * FROM scored_history WHERE customer_key = ANY(${customerKeysParam}); `, historyParams); historyRows = historyResult.rows; } const historyClients = buildRfmClients(historyRows.map(row => ({ customerKey: row.customer_key, clientToken: createClientToken(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), rfmFrequencyScore: row.frequency_score === undefined ? undefined : toNumber(row.frequency_score), rfmMonetaryScore: row.monetary_score === undefined ? undefined : toNumber(row.monetary_score) }))); const tagsByCustomerKey = new Map(historyClients.map(historyClient => [historyClient.customerKey, historyClient])); const clients = periodResult.rows.map(row => { const taggedClient = tagsByCustomerKey.get(row.customer_key); if (!taggedClient) { const [fallbackClient] = buildRfmClients([{ customerKey: row.customer_key, clientToken: createClientToken(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) }]); return fallbackClient; } return { ...taggedClient, customerKey: row.customer_key, clientToken: taggedClient.clientToken || createClientToken(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; }); await persistClientTokenMappings(clients, client); await client.query('COMMIT'); return { range: { start: normalizeDateParam(range.start), end: normalizeDateParam(range.end) }, clients, segments: buildRfmSegments(clients), matrix: { recencyScores: [3, 2, 1], valueScores: [1, 2, 3] } }; } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } finally { client.release(); } }; module.exports = { buildDateFilter, buildRfmClients, buildRfmSegments, createClientToken, isClientToken, getFrequencyScore, getClientDetailsAnalytics, getPreviousDate, getRecencyScore, getRfmAnalytics, getRfmSegment, getClientAnalytics, getDashboardAnalytics, getProductAnalytics, normalizeDateParam, scoreTertile };