From 9953dd31497c1142ae0174518ba2ff7ef69e0c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 22 Jun 2026 10:12:41 -0300 Subject: [PATCH] Calibrate RFV frequency scoring --- backend/services/analyticsService.js | 25 +++++++++---- backend/test/analyticsService.test.js | 51 ++++++++++++++++++++++++--- src/pages/Rfm.tsx | 6 ++-- 3 files changed, 68 insertions(+), 14 deletions(-) diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index f9592aa..44d4ced 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -4,6 +4,7 @@ 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 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 @@ -133,6 +134,13 @@ const getRecencyScore = (recencyDays) => { 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, @@ -157,8 +165,11 @@ const getLifecycleSegment = ({ return { segment: RFM_SEGMENTS_BY_KEY.get('new_customers'), valueScore: 1 }; } - if (days <= RECENT_MAX_DAYS && valueScore === 1) { - return { segment: RFM_SEGMENTS_BY_KEY.get('potential_loyalists'), valueScore: 2 }; + 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 { @@ -200,7 +211,6 @@ const buildRfmSegments = (clients) => { }; const buildRfmClients = (baseClients) => { - const frequencyScoreFor = buildTertileScorer(baseClients.map(client => client.rfmFrequency ?? client.frequency), true); const monetaryScoreFor = buildTertileScorer(baseClients.map(client => client.rfmMonetary ?? client.monetary), true); return baseClients.map(client => { @@ -208,7 +218,7 @@ const buildRfmClients = (baseClients) => { const historicalFrequency = toNumber(client.rfmFrequency ?? client.frequency); const historicalMonetary = toNumber(client.rfmMonetary ?? client.monetary); const recencyScore = getRecencyScore(recencyDays); - const frequencyScore = client.rfmFrequencyScore ?? frequencyScoreFor(historicalFrequency); + 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({ @@ -462,9 +472,9 @@ const getRfmAnalytics = async (range = {}) => { SELECT customer_history.*, CASE - WHEN COUNT(*) OVER () = 1 THEN 3 - WHEN MIN(frequency) OVER () = MAX(frequency) OVER () THEN 2 - ELSE LEAST(3, GREATEST(1, FLOOR(PERCENT_RANK() OVER (ORDER BY frequency) * 3)::int + 1)) + 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 @@ -552,6 +562,7 @@ module.exports = { buildDateFilter, buildRfmClients, buildRfmSegments, + getFrequencyScore, getPreviousDate, getRecencyScore, getRfmAnalytics, diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index ab550d5..31f8a48 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -5,6 +5,7 @@ const { buildRfmClients, buildRfmSegments, buildDateFilter, + getFrequencyScore, getPreviousDate, getRecencyScore, getRfmAnalytics, @@ -120,6 +121,15 @@ test('getRecencyScore uses fixed lifecycle boundaries', () => { assert.equal(getRecencyScore(366), 1); }); +test('getFrequencyScore uses fixed historical order thresholds', () => { + assert.equal(getFrequencyScore(0), 1); + assert.equal(getFrequencyScore(1), 1); + assert.equal(getFrequencyScore(2), 2); + assert.equal(getFrequencyScore(4), 2); + assert.equal(getFrequencyScore(5), 3); + assert.equal(getFrequencyScore(100), 3); +}); + test('getRfmSegment maps the 3x3 RFM matrix', () => { assert.deepEqual(getRfmSegment(3, 3), { key: 'champions', label: 'Champions' }); assert.deepEqual(getRfmSegment(2, 2), { key: 'need_attention', label: 'Precisam de Atenção' }); @@ -143,7 +153,7 @@ test('buildRfmSegments summarizes segment count and revenue', () => { assert.equal(lost.totalRevenue, 25); }); -test('buildRfmClients keeps a yesterday buyer in the same high-recency segment when it is the only qualifying client', () => { +test('buildRfmClients keeps a repeat recent buyer as potential loyal across period filters', () => { const sevenDayClients = buildRfmClients([ { name: 'Cliente Ontem', phone: '1', monetary: 500, frequency: 2, quantityPurchased: 2, lastPurchaseDate: '2026-06-14', recencyDays: 1 } ]); @@ -151,11 +161,42 @@ test('buildRfmClients keeps a yesterday buyer in the same high-recency segment w { name: 'Cliente Ontem', phone: '1', monetary: 500, frequency: 2, quantityPurchased: 2, lastPurchaseDate: '2026-06-14', recencyDays: 0 } ]); - assert.equal(sevenDayClients[0].segmentKey, 'champions'); - assert.equal(yesterdayClients[0].segmentKey, 'champions'); + assert.equal(sevenDayClients[0].segmentKey, 'potential_loyalists'); + assert.equal(yesterdayClients[0].segmentKey, 'potential_loyalists'); assert.equal(sevenDayClients[0].phone, yesterdayClients[0].phone); }); +test('buildRfmClients requires five orders and top value for a recent Champion', () => { + const clients = buildRfmClients([ + { + customerKey: 'potential', + name: 'Potencial', + monetary: 5000, + frequency: 4, + recencyDays: 10, + rfmFrequency: 4, + rfmMonetary: 5000, + rfmMonetaryScore: 3 + }, + { + customerKey: 'champion', + name: 'Champion', + monetary: 5000, + frequency: 5, + recencyDays: 10, + rfmFrequency: 5, + rfmMonetary: 5000, + rfmMonetaryScore: 3 + } + ]); + const byKey = new Map(clients.map(client => [client.customerKey, client])); + + assert.equal(byKey.get('potential').frequencyScore, 2); + assert.equal(byKey.get('potential').segmentKey, 'potential_loyalists'); + assert.equal(byKey.get('champion').frequencyScore, 3); + assert.equal(byKey.get('champion').segmentKey, 'champions'); +}); + test('buildRfmClients scores segments from RFM history when period totals are smaller', () => { const clients = buildRfmClients([ { @@ -376,7 +417,9 @@ test('getRfmAnalytics classifies period buyers by history through the selected r assert.doesNotMatch(selectCalls[0].sql, /cliente_fone IS NOT NULL/); assert.match(selectCalls[1].sql, /\(\$1::date - MAX\(data_pedido_date\)\)::int/); assert.match(selectCalls[1].sql, /data_pedido_date <= \$1::date/); - assert.match(selectCalls[1].sql, /PERCENT_RANK\(\) OVER \(ORDER BY frequency\)/); + assert.match(selectCalls[1].sql, /WHEN frequency <= 1 THEN 1/); + assert.match(selectCalls[1].sql, /WHEN frequency <= 4 THEN 2/); + assert.doesNotMatch(selectCalls[1].sql, /PERCENT_RANK\(\) OVER \(ORDER BY frequency\)/); assert.match(selectCalls[1].sql, /PERCENT_RANK\(\) OVER \(ORDER BY monetary\)/); assert.match(selectCalls[1].sql, /customer_key = ANY\(\$2::text\[\]\)/); assert.doesNotMatch(selectCalls[1].sql, /cliente_fone IS NOT NULL/); diff --git a/src/pages/Rfm.tsx b/src/pages/Rfm.tsx index 66d2c10..d86a4c7 100644 --- a/src/pages/Rfm.tsx +++ b/src/pages/Rfm.tsx @@ -33,8 +33,8 @@ const rfmSegmentDefinitions: Array> = [ ]; const segmentDescriptions: Record = { - champions: 'Recentes, frequentes e valiosos', - potential_loyalists: 'Recentes e em evolução', + champions: 'Recentes, 5+ pedidos e valor alto', + potential_loyalists: 'Recentes com 2+ pedidos em evolução', new_customers: 'Primeiro pedido nos últimos 60 dias', loyal_customers: 'Bom histórico, compra entre 61 e 180 dias', need_attention: 'Perfil médio, compra entre 61 e 180 dias', @@ -312,7 +312,7 @@ const Rfm = () => {

Matriz RFV

-

Compradores do período agrupados por recência fixa e perfil histórico de frequência e valor.

+

Recência fixa, frequência por número de pedidos e valor relativo ao histórico.

Menor prioridade