Fix RFV period buyers with historical scoring
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m34s

This commit is contained in:
Cauê Faleiros
2026-06-17 15:07:59 -03:00
parent 10c69457ba
commit 3a907b0743
6 changed files with 89 additions and 48 deletions

View File

@@ -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),

View File

@@ -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;
}

View File

@@ -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<string, {
name: string;
totalSpent: number;
totalItems: number;
uniqueOrders: Set<string>;
@@ -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

View File

@@ -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 = () => {
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
{paginatedData.map((client, index) => (
<tr key={client.name} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
<tr key={client.customerKey} className="hover:bg-zinc-50/80 dark:hover:bg-dark-input/50 transition-colors group">
<td className="px-6 py-2.5">
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold bg-zinc-100 dark:bg-dark-border text-zinc-500 dark:text-dark-muted">
{startIndex + index + 1}

View File

@@ -292,7 +292,7 @@ const Rfm = () => {
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<p className="text-dark-muted text-sm font-medium mb-1">Clientes no Período</p>
<h3 className="text-3xl font-bold text-dark-text">{clients.length}</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">Agrupados pela tag RFV anterior</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Segmento RFV calculado até o fim do período</p>
</div>
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<p className="text-dark-muted text-sm font-medium mb-1">Receita no Período</p>
@@ -312,7 +312,7 @@ const Rfm = () => {
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-lg font-bold text-dark-text">Matriz RFV</h2>
<p className="text-sm font-medium text-dark-muted">Compradores do período agrupados pela tag RFV anterior ao período.</p>
<p className="text-sm font-medium text-dark-muted">Compradores do período agrupados pelo RFV histórico até a data final.</p>
</div>
<div className="flex items-center gap-2 text-xs font-semibold text-dark-muted">
<span>Menor prioridade</span>
@@ -531,7 +531,7 @@ const Rfm = () => {
{paginatedClients.map((client: RfmClient) => {
const style = segmentStyles[client.segmentKey] || segmentStyles.lost;
return (
<tr key={client.phone} className="hover:bg-dark-input/50 transition-colors">
<tr key={client.customerKey} className="hover:bg-dark-input/50 transition-colors">
<td className="px-6 py-3">
<Link to={`/clients/${encodeURIComponent(client.name)}`} className="flex items-center gap-3 hover:text-brand-primary transition-colors">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-dark-input text-dark-muted">

View File

@@ -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;