Calibrate RFV customer lifecycle segments
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m30s
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user