Calibrate RFV customer lifecycle segments
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s

This commit is contained in:
Cauê Faleiros
2026-06-18 15:51:35 -03:00
parent ca90a6e94b
commit 1af624aa77
3 changed files with 219 additions and 81 deletions

View File

@@ -1,6 +1,9 @@
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 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
@@ -77,6 +80,9 @@ const RFM_SEGMENTS = {
'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);
@@ -120,6 +126,47 @@ 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 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 && valueScore === 1) {
return { 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);
@@ -153,21 +200,30 @@ const buildRfmSegments = (clients) => {
};
const buildRfmClients = (baseClients) => {
const recencyScoreFor = buildTertileScorer(baseClients.map(client => client.recencyDays), false);
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 => {
const frequencyForScore = client.rfmFrequency ?? client.frequency;
const monetaryForScore = client.rfmMonetary ?? client.monetary;
const recencyScore = recencyScoreFor(client.recencyDays);
const frequencyScore = frequencyScoreFor(frequencyForScore);
const monetaryScore = monetaryScoreFor(monetaryForScore);
const valueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2)));
const segment = getRfmSegment(recencyScore, valueScore);
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 ?? frequencyScoreFor(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,
@@ -377,39 +433,49 @@ const getRfmAnalytics = async (range = {}) => {
let historyRows = periodResult.rows;
if (!usePeriodAsHistory) {
const periodPhones = [...new Set(periodResult.rows.map(row => row.phone).filter(Boolean))];
const periodNamesWithoutPhone = [...new Set(periodResult.rows
.filter(row => !row.phone && String(row.customer_key || '').startsWith('name:'))
.map(row => String(row.customer_key).slice(5))
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 phoneListParam = `$${historyParams.push(periodPhones)}::text[]`;
const nameListParam = `$${historyParams.push(periodNamesWithoutPhone)}::text[]`;
const customerKeysParam = `$${historyParams.push(periodCustomerKeys)}::text[]`;
const historyResult = 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((${recencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days
FROM orders
WHERE data_pedido_date IS NOT NULL
AND data_pedido_date <= ${recencyReferenceDate}
AND (
NULLIF(cliente_fone, '') = ANY(${phoneListParam})
OR (
NULLIF(cliente_fone, '') IS NULL
AND COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido') = ANY(${nameListParam})
)
)
GROUP BY customer_key;
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 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))
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;
}
@@ -422,15 +488,16 @@ const getRfmAnalytics = async (range = {}) => {
frequency: toNumber(row.frequency),
quantityPurchased: toNumber(row.quantity_purchased),
lastPurchaseDate: row.last_purchase_date,
recencyDays: toNumber(row.recency_days)
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 newCustomerSegment = getRfmSegment(3, 1);
return {
const [fallbackClient] = buildRfmClients([{
customerKey: row.customer_key,
name: row.name,
phone: row.phone || '',
@@ -438,15 +505,9 @@ const getRfmAnalytics = async (range = {}) => {
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
};
recencyDays: toNumber(row.recency_days)
}]);
return fallbackClient;
}
return {
@@ -492,6 +553,7 @@ module.exports = {
buildRfmClients,
buildRfmSegments,
getPreviousDate,
getRecencyScore,
getRfmAnalytics,
getRfmSegment,
getClientAnalytics,

View File

@@ -6,6 +6,7 @@ const {
buildRfmSegments,
buildDateFilter,
getPreviousDate,
getRecencyScore,
getRfmAnalytics,
getRfmSegment,
normalizeDateParam,
@@ -109,6 +110,16 @@ test('scoreTertile treats equal recency as high recency', () => {
assert.equal(scoreTertile(0, [0, 0, 0], false), 3);
});
test('getRecencyScore uses fixed lifecycle boundaries', () => {
assert.equal(getRecencyScore(0), 3);
assert.equal(getRecencyScore(60), 3);
assert.equal(getRecencyScore(61), 2);
assert.equal(getRecencyScore(180), 2);
assert.equal(getRecencyScore(181), 1);
assert.equal(getRecencyScore(365), 1);
assert.equal(getRecencyScore(366), 1);
});
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' });
@@ -191,6 +202,74 @@ test('buildRfmClients scores segments from RFM history when period totals are sm
assert.equal(yesterdayBuyer.monetaryScore, 3);
});
test('buildRfmClients applies lifecycle protections to new, hibernating, at-risk, and lost clients', () => {
const clients = buildRfmClients([
{
customerKey: 'new',
name: 'Novo',
monetary: 50,
frequency: 1,
recencyDays: 60,
rfmFrequency: 1,
rfmMonetary: 50,
rfmFrequencyScore: 1,
rfmMonetaryScore: 1
},
{
customerKey: 'not-new',
name: 'Não é mais novo',
monetary: 50,
frequency: 1,
recencyDays: 61,
rfmFrequency: 1,
rfmMonetary: 50,
rfmFrequencyScore: 1,
rfmMonetaryScore: 1
},
{
customerKey: 'hibernating',
name: 'Hibernando',
monetary: 50,
frequency: 1,
recencyDays: 250,
rfmFrequency: 1,
rfmMonetary: 50,
rfmFrequencyScore: 1,
rfmMonetaryScore: 1
},
{
customerKey: 'at-risk',
name: 'Em Risco',
monetary: 100,
frequency: 1,
recencyDays: 250,
rfmFrequency: 10,
rfmMonetary: 1000,
rfmFrequencyScore: 3,
rfmMonetaryScore: 2
},
{
customerKey: 'lost',
name: 'Perdido',
monetary: 2000,
frequency: 1,
recencyDays: 366,
rfmFrequency: 1,
rfmMonetary: 2000,
rfmFrequencyScore: 1,
rfmMonetaryScore: 3
}
]);
const byKey = new Map(clients.map(client => [client.customerKey, client]));
assert.equal(byKey.get('new').segmentKey, 'new_customers');
assert.equal(byKey.get('not-new').segmentKey, 'about_to_sleep');
assert.equal(byKey.get('hibernating').segmentKey, 'hibernating');
assert.equal(byKey.get('at-risk').segmentKey, 'at_risk');
assert.equal(byKey.get('lost').segmentKey, 'lost');
assert.equal(byKey.get('lost').rfmScore, '113');
});
test('getRfmAnalytics classifies period buyers by history through the selected range end', async () => {
const originalConnect = pool.connect;
const calls = [];
@@ -216,27 +295,21 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
frequency: 20,
quantity_purchased: 20,
last_purchase_date: '2026-06-14',
recency_days: referenceDate === '2026-06-14' ? 0 : 1
recency_days: referenceDate === '2026-06-14' ? 0 : 1,
frequency_score: 3,
monetary_score: 3
},
{
customer_key: '2',
name: 'Cliente Antigo',
phone: '2',
monetary: 50,
customer_key: '4',
name: 'Cliente Novo no Periodo',
phone: '4',
monetary: 25,
frequency: 1,
quantity_purchased: 1,
last_purchase_date: '2026-01-01',
recency_days: 164
},
{
customer_key: '3',
name: 'Cliente Medio',
phone: '3',
monetary: 100,
frequency: 2,
quantity_purchased: 2,
last_purchase_date: '2026-03-01',
recency_days: 105
last_purchase_date: '2026-06-14',
recency_days: referenceDate === '2026-06-14' ? 0 : 1,
frequency_score: 1,
monetary_score: 1
},
{
customer_key: 'name:Cliente Sem Fone',
@@ -246,7 +319,9 @@ test('getRfmAnalytics classifies period buyers by history through the selected r
frequency: 10,
quantity_purchased: 10,
last_purchase_date: '2026-06-14',
recency_days: 1
recency_days: referenceDate === '2026-06-14' ? 0 : 1,
frequency_score: 3,
monetary_score: 3
}
]
};
@@ -301,12 +376,13 @@ 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, /NULLIF\(cliente_fone, ''\) = ANY\(\$2::text\[\]\)/);
assert.match(selectCalls[1].sql, /COALESCE\(NULLIF\(cliente_nome, ''\), 'Cliente Desconhecido'\) = ANY\(\$3::text\[\]\)/);
assert.match(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/);
assert.deepEqual(selectCalls[1].params, ['2026-06-15', ['1', '4'], ['Cliente Sem Fone']]);
assert.deepEqual(selectCalls[1].params, ['2026-06-15', ['1', '4', 'name:Cliente Sem Fone']]);
assert.deepEqual(selectCalls[2].params, ['2026-06-14', '2026-06-14']);
assert.deepEqual(selectCalls[3].params, ['2026-06-14', ['1', '4'], ['Cliente Sem Fone']]);
assert.deepEqual(selectCalls[3].params, ['2026-06-14', ['1', '4', 'name:Cliente Sem Fone']]);
assert.equal(sevenDays.clients[0].segmentKey, 'champions');
assert.equal(yesterday.clients[0].segmentKey, 'champions');
assert.equal(yesterday.clients[0].frequency, 1);