From 3a907b0743df2ad3bd2a58fcb788ca03f5bc9138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Wed, 17 Jun 2026 15:07:59 -0300 Subject: [PATCH] Fix RFV period buyers with historical scoring --- backend/services/analyticsService.js | 44 ++++++++++++++------------- backend/test/analyticsService.test.js | 44 +++++++++++++++++++++++---- src/analytics/clients.ts | 34 ++++++++++++--------- src/pages/Clients.tsx | 7 +++-- src/pages/Rfm.tsx | 6 ++-- src/types.ts | 2 ++ 6 files changed, 89 insertions(+), 48 deletions(-) diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index 097dace..a6e2d22 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -7,6 +7,7 @@ const PRODUCT_NAME_SQL = ` 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; @@ -235,7 +236,8 @@ const getClientAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); const result = await pool.query(` SELECT - COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido') as name, + ${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, @@ -243,11 +245,12 @@ const getClientAnalytics = async (range = {}) => { MAX(data_pedido_date) as last_purchase_date FROM orders ${whereClause} - GROUP BY COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido') + 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), @@ -259,32 +262,30 @@ const getClientAnalytics = async (range = {}) => { 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 recencyReferenceDate = normalizedEnd ? '$1::date' : 'CURRENT_DATE'; + const historyParams = normalizedEnd ? [normalizedEnd] : []; const [periodResult, historyResult] = await Promise.all([ pool.query(` SELECT - MAX(cliente_nome) as name, - cliente_fone as phone, + ${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 FROM orders ${whereClause} - AND cliente_fone IS NOT NULL - AND cliente_fone != '' - GROUP BY cliente_fone + GROUP BY customer_key ORDER BY monetary DESC; `, params), pool.query(` SELECT - MAX(cliente_nome) as name, - cliente_fone as phone, + ${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, @@ -293,30 +294,30 @@ const getRfmAnalytics = async (range = {}) => { FROM orders WHERE data_pedido_date IS NOT NULL AND data_pedido_date <= ${recencyReferenceDate} - AND cliente_fone IS NOT NULL - AND cliente_fone != '' - GROUP BY cliente_fone; + GROUP BY customer_key; `, historyParams) ]); const historyClients = buildRfmClients(historyResult.rows.map(row => ({ + customerKey: row.customer_key, name: row.name, - phone: row.phone, + 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 tagsByPhone = new Map(historyClients.map(client => [client.phone, client])); + const tagsByCustomerKey = new Map(historyClients.map(client => [client.customerKey, client])); const clients = periodResult.rows.map(row => { - const taggedClient = tagsByPhone.get(row.phone); + 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, + phone: row.phone || '', monetary: toNumber(row.monetary), frequency: toNumber(row.frequency), quantityPurchased: toNumber(row.quantity_purchased), @@ -334,8 +335,9 @@ const getRfmAnalytics = async (range = {}) => { return { ...taggedClient, + customerKey: row.customer_key, name: row.name, - phone: row.phone, + phone: row.phone || '', monetary: toNumber(row.monetary), frequency: toNumber(row.frequency), quantityPurchased: toNumber(row.quantity_purchased), diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index a878e8c..7842619 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -191,28 +191,30 @@ test('buildRfmClients scores segments from RFM history when period totals are sm assert.equal(yesterdayBuyer.monetaryScore, 3); }); -test('getRfmAnalytics groups period buyers by their RFM tag before the selected period', async () => { +test('getRfmAnalytics classifies period buyers by history through the selected range end', async () => { const originalQuery = pool.query; const calls = []; pool.query = async (sql, params) => { calls.push({ sql, params }); const isHistoryQuery = sql.includes('recency_days'); - const endDate = params[0]; + const referenceDate = params[0]; if (isHistoryQuery) { return { rows: [ { + customer_key: '1', name: 'Cliente Ontem', phone: '1', monetary: 5000, frequency: 20, quantity_purchased: 20, last_purchase_date: '2026-06-14', - recency_days: endDate === '2026-06-13' ? 0 : 1 + recency_days: referenceDate === '2026-06-14' ? 0 : 1 }, { + customer_key: '2', name: 'Cliente Antigo', phone: '2', monetary: 50, @@ -222,6 +224,7 @@ test('getRfmAnalytics groups period buyers by their RFM tag before the selected recency_days: 164 }, { + customer_key: '3', name: 'Cliente Medio', phone: '3', monetary: 100, @@ -229,6 +232,16 @@ test('getRfmAnalytics groups period buyers by their RFM tag before the selected quantity_purchased: 2, last_purchase_date: '2026-03-01', recency_days: 105 + }, + { + customer_key: 'name:Cliente Sem Fone', + name: 'Cliente Sem Fone', + phone: null, + monetary: 1000, + frequency: 10, + quantity_purchased: 10, + last_purchase_date: '2026-06-14', + recency_days: 1 } ] }; @@ -237,6 +250,7 @@ test('getRfmAnalytics groups period buyers by their RFM tag before the selected return { rows: [ { + customer_key: '1', name: 'Cliente Ontem', phone: '1', monetary: 100, @@ -245,12 +259,22 @@ test('getRfmAnalytics groups period buyers by their RFM tag before the selected last_purchase_date: '2026-06-14' }, { + customer_key: '4', name: 'Cliente Novo no Periodo', phone: '4', monetary: 25, frequency: 1, quantity_purchased: 1, last_purchase_date: '2026-06-14' + }, + { + customer_key: 'name:Cliente Sem Fone', + name: 'Cliente Sem Fone', + phone: null, + monetary: 30, + frequency: 1, + quantity_purchased: 1, + last_purchase_date: '2026-06-14' } ] }; @@ -261,18 +285,26 @@ test('getRfmAnalytics groups period buyers by their RFM tag before the selected const yesterday = await getRfmAnalytics({ start: '2026-06-14', end: '2026-06-14' }); assert.deepEqual(calls[0].params, ['2026-06-09', '2026-06-15']); + assert.doesNotMatch(calls[0].sql, /cliente_fone IS NOT NULL/); assert.match(calls[1].sql, /\(\$1::date - MAX\(data_pedido_date\)\)::int/); assert.match(calls[1].sql, /data_pedido_date <= \$1::date/); - assert.deepEqual(calls[1].params, ['2026-06-08']); + assert.doesNotMatch(calls[1].sql, /cliente_fone IS NOT NULL/); + assert.deepEqual(calls[1].params, ['2026-06-15']); assert.deepEqual(calls[2].params, ['2026-06-14', '2026-06-14']); - assert.deepEqual(calls[3].params, ['2026-06-13']); + assert.deepEqual(calls[3].params, ['2026-06-14']); assert.equal(sevenDays.clients[0].segmentKey, 'champions'); assert.equal(yesterday.clients[0].segmentKey, 'champions'); assert.equal(yesterday.clients[0].frequency, 1); assert.equal(yesterday.clients[0].monetary, 100); - assert.equal(yesterday.clients.length, 2); + assert.equal(yesterday.clients.length, 3); assert.ok(!yesterday.clients.some(client => client.phone === '2')); assert.ok(yesterday.clients.some(client => client.phone === '4' && client.segmentKey === 'new_customers')); + assert.ok(yesterday.clients.some(client => ( + client.customerKey === 'name:Cliente Sem Fone' && + client.phone === '' && + client.segmentKey === 'champions' && + client.monetary === 30 + ))); } finally { pool.query = originalQuery; } diff --git a/src/analytics/clients.ts b/src/analytics/clients.ts index ab83242..94e16d4 100644 --- a/src/analytics/clients.ts +++ b/src/analytics/clients.ts @@ -13,6 +13,7 @@ export type ClientSortOption = | 'items_asc'; export interface ClientSummary { + customerKey: string; name: string; phone: string; totalSpent: number; @@ -113,6 +114,7 @@ export const buildClientsSummary = ( ): ClientSummary[] => { const orders = filterOrdersByDateRange(ordersData, dateRange); const clientMap: Record; @@ -122,33 +124,35 @@ export const buildClientsSummary = ( orders.forEach(order => { const clientName = getClientDisplayName(order); + const customerKey = order.Fone_Cliente || `name:${clientName}`; - if (!clientMap[clientName]) { - clientMap[clientName] = { totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0, phone: '' }; + if (!clientMap[customerKey]) { + clientMap[customerKey] = { name: clientName, totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0, phone: '' }; } if (order.Fone_Cliente) { - clientMap[clientName].phone = order.Fone_Cliente; + clientMap[customerKey].phone = order.Fone_Cliente; } - clientMap[clientName].totalSpent += getOrderItemRevenue(order); - clientMap[clientName].totalItems += order.Quantidade; - clientMap[clientName].uniqueOrders.add(`${order.Data_Pedido}_${order.Valor_Pedido}`); + clientMap[customerKey].totalSpent += getOrderItemRevenue(order); + clientMap[customerKey].totalItems += order.Quantidade; + clientMap[customerKey].uniqueOrders.add(`${order.Data_Pedido}_${order.Valor_Pedido}`); const orderTime = parseOrderDate(order.Data_Pedido).getTime(); - if (orderTime > clientMap[clientName].lastPurchase) { - clientMap[clientName].lastPurchase = orderTime; + if (orderTime > clientMap[customerKey].lastPurchase) { + clientMap[customerKey].lastPurchase = orderTime; } }); const normalizedSearch = searchTerm.trim().toLowerCase(); - const clients = enrichClientsWithRfmType(Object.keys(clientMap).map(name => ({ - name, - phone: clientMap[name].phone, - totalSpent: clientMap[name].totalSpent, - totalItems: clientMap[name].totalItems, - orderCount: clientMap[name].uniqueOrders.size, - lastPurchase: clientMap[name].lastPurchase + const clients = enrichClientsWithRfmType(Object.keys(clientMap).map(customerKey => ({ + customerKey, + name: clientMap[customerKey].name, + phone: clientMap[customerKey].phone, + totalSpent: clientMap[customerKey].totalSpent, + totalItems: clientMap[customerKey].totalItems, + orderCount: clientMap[customerKey].uniqueOrders.size, + lastPurchase: clientMap[customerKey].lastPurchase })), dateRange); const filteredClients = normalizedSearch diff --git a/src/pages/Clients.tsx b/src/pages/Clients.tsx index d1219c2..dc487d8 100644 --- a/src/pages/Clients.tsx +++ b/src/pages/Clients.tsx @@ -102,10 +102,11 @@ const Clients = () => { const allClientsData = useMemo(() => { const normalizedSearch = searchTerm.trim().toLowerCase(); - const rfmByPhone = new Map((rfmAnalytics?.clients || []).map(client => [client.phone, client])); + const rfmByCustomerKey = new Map((rfmAnalytics?.clients || []).map(client => [client.customerKey, client])); const clients = clientAnalytics.map((client): ClientSummary => { - const rfmClient = client.phone ? rfmByPhone.get(client.phone) : undefined; + const rfmClient = rfmByCustomerKey.get(client.customerKey); return { + customerKey: client.customerKey, name: client.name, phone: client.phone, totalSpent: client.totalSpent, @@ -281,7 +282,7 @@ const Clients = () => { {paginatedData.map((client, index) => ( - + {startIndex + index + 1} diff --git a/src/pages/Rfm.tsx b/src/pages/Rfm.tsx index 51810e1..64fc89c 100644 --- a/src/pages/Rfm.tsx +++ b/src/pages/Rfm.tsx @@ -292,7 +292,7 @@ const Rfm = () => {

Clientes no Período

{clients.length}

-

Agrupados pela tag RFV anterior

+

Segmento RFV calculado até o fim do período

Receita no Período

@@ -312,7 +312,7 @@ const Rfm = () => {

Matriz RFV

-

Compradores do período agrupados pela tag RFV anterior ao período.

+

Compradores do período agrupados pelo RFV histórico até a data final.

Menor prioridade @@ -531,7 +531,7 @@ const Rfm = () => { {paginatedClients.map((client: RfmClient) => { const style = segmentStyles[client.segmentKey] || segmentStyles.lost; return ( - + diff --git a/src/types.ts b/src/types.ts index be2e367..3abbb59 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,6 +64,7 @@ export interface DashboardAnalytics { } export interface ClientAnalyticsItem { + customerKey: string; name: string; phone: string; quantityPurchased: number; @@ -73,6 +74,7 @@ export interface ClientAnalyticsItem { } export interface RfmClient { + customerKey: string; name: string; phone: string; monetary: number;