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')), '') ELSE NULLIF(TRIM(regexp_replace(split_part(COALESCE(produto_descricao, 'Unknown'), ' TAMANHO', 1), '${SIZE_SUFFIX_SQL_PATTERN}', '', 'i')), '')
END END
`; `;
const CUSTOMER_KEY_SQL = "COALESCE(NULLIF(cliente_fone, ''), 'name:' || COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido'))";
const normalizeDateParam = (value) => { const normalizeDateParam = (value) => {
if (!value) return null; if (!value) return null;
@@ -235,7 +236,8 @@ const getClientAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range); const { params, whereClause } = buildDateFilter(range);
const result = await pool.query(` const result = await pool.query(`
SELECT 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, MAX(NULLIF(cliente_fone, '')) as phone,
COALESCE(SUM(quantidade), 0) as quantity_purchased, COALESCE(SUM(quantidade), 0) as quantity_purchased,
COALESCE(SUM(quantidade * valor_unitario), 0) as total_spent, 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 MAX(data_pedido_date) as last_purchase_date
FROM orders FROM orders
${whereClause} ${whereClause}
GROUP BY COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido') GROUP BY customer_key
ORDER BY total_spent DESC; ORDER BY total_spent DESC;
`, params); `, params);
return result.rows.map(row => ({ return result.rows.map(row => ({
customerKey: row.customer_key,
name: row.name, name: row.name,
phone: row.phone || '', phone: row.phone || '',
quantityPurchased: toNumber(row.quantity_purchased), quantityPurchased: toNumber(row.quantity_purchased),
@@ -259,32 +262,30 @@ const getClientAnalytics = async (range = {}) => {
const getRfmAnalytics = async (range = {}) => { const getRfmAnalytics = async (range = {}) => {
const { params, whereClause } = buildDateFilter(range); const { params, whereClause } = buildDateFilter(range);
const normalizedStart = normalizeDateParam(range.start);
const normalizedEnd = normalizeDateParam(range.end); const normalizedEnd = normalizeDateParam(range.end);
const tagReference = getPreviousDate(normalizedStart) || normalizedEnd; const recencyReferenceDate = normalizedEnd ? '$1::date' : 'CURRENT_DATE';
const recencyReferenceDate = tagReference ? '$1::date' : 'CURRENT_DATE'; const historyParams = normalizedEnd ? [normalizedEnd] : [];
const historyParams = tagReference ? [tagReference] : [];
const [periodResult, historyResult] = await Promise.all([ const [periodResult, historyResult] = await Promise.all([
pool.query(` pool.query(`
SELECT SELECT
MAX(cliente_nome) as name, ${CUSTOMER_KEY_SQL} as customer_key,
cliente_fone as phone, MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name,
MAX(NULLIF(cliente_fone, '')) as phone,
COALESCE(SUM(quantidade * valor_unitario), 0) as monetary, COALESCE(SUM(quantidade * valor_unitario), 0) as monetary,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency,
COALESCE(SUM(quantidade), 0) as quantity_purchased, COALESCE(SUM(quantidade), 0) as quantity_purchased,
MAX(data_pedido_date) as last_purchase_date MAX(data_pedido_date) as last_purchase_date
FROM orders FROM orders
${whereClause} ${whereClause}
AND cliente_fone IS NOT NULL GROUP BY customer_key
AND cliente_fone != ''
GROUP BY cliente_fone
ORDER BY monetary DESC; ORDER BY monetary DESC;
`, params), `, params),
pool.query(` pool.query(`
SELECT SELECT
MAX(cliente_nome) as name, ${CUSTOMER_KEY_SQL} as customer_key,
cliente_fone as phone, MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name,
MAX(NULLIF(cliente_fone, '')) as phone,
COALESCE(SUM(quantidade * valor_unitario), 0) as monetary, COALESCE(SUM(quantidade * valor_unitario), 0) as monetary,
COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency,
COALESCE(SUM(quantidade), 0) as quantity_purchased, COALESCE(SUM(quantidade), 0) as quantity_purchased,
@@ -293,30 +294,30 @@ const getRfmAnalytics = async (range = {}) => {
FROM orders FROM orders
WHERE data_pedido_date IS NOT NULL WHERE data_pedido_date IS NOT NULL
AND data_pedido_date <= ${recencyReferenceDate} AND data_pedido_date <= ${recencyReferenceDate}
AND cliente_fone IS NOT NULL GROUP BY customer_key;
AND cliente_fone != ''
GROUP BY cliente_fone;
`, historyParams) `, historyParams)
]); ]);
const historyClients = buildRfmClients(historyResult.rows.map(row => ({ const historyClients = buildRfmClients(historyResult.rows.map(row => ({
customerKey: row.customer_key,
name: row.name, name: row.name,
phone: row.phone, phone: row.phone || '',
monetary: toNumber(row.monetary), monetary: toNumber(row.monetary),
frequency: toNumber(row.frequency), frequency: toNumber(row.frequency),
quantityPurchased: toNumber(row.quantity_purchased), quantityPurchased: toNumber(row.quantity_purchased),
lastPurchaseDate: row.last_purchase_date, lastPurchaseDate: row.last_purchase_date,
recencyDays: toNumber(row.recency_days) 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 clients = periodResult.rows.map(row => {
const taggedClient = tagsByPhone.get(row.phone); const taggedClient = tagsByCustomerKey.get(row.customer_key);
if (!taggedClient) { if (!taggedClient) {
const newCustomerSegment = getRfmSegment(3, 1); const newCustomerSegment = getRfmSegment(3, 1);
return { return {
customerKey: row.customer_key,
name: row.name, name: row.name,
phone: row.phone, phone: row.phone || '',
monetary: toNumber(row.monetary), monetary: toNumber(row.monetary),
frequency: toNumber(row.frequency), frequency: toNumber(row.frequency),
quantityPurchased: toNumber(row.quantity_purchased), quantityPurchased: toNumber(row.quantity_purchased),
@@ -334,8 +335,9 @@ const getRfmAnalytics = async (range = {}) => {
return { return {
...taggedClient, ...taggedClient,
customerKey: row.customer_key,
name: row.name, name: row.name,
phone: row.phone, phone: row.phone || '',
monetary: toNumber(row.monetary), monetary: toNumber(row.monetary),
frequency: toNumber(row.frequency), frequency: toNumber(row.frequency),
quantityPurchased: toNumber(row.quantity_purchased), 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); 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 originalQuery = pool.query;
const calls = []; const calls = [];
pool.query = async (sql, params) => { pool.query = async (sql, params) => {
calls.push({ sql, params }); calls.push({ sql, params });
const isHistoryQuery = sql.includes('recency_days'); const isHistoryQuery = sql.includes('recency_days');
const endDate = params[0]; const referenceDate = params[0];
if (isHistoryQuery) { if (isHistoryQuery) {
return { return {
rows: [ rows: [
{ {
customer_key: '1',
name: 'Cliente Ontem', name: 'Cliente Ontem',
phone: '1', phone: '1',
monetary: 5000, monetary: 5000,
frequency: 20, frequency: 20,
quantity_purchased: 20, quantity_purchased: 20,
last_purchase_date: '2026-06-14', 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', name: 'Cliente Antigo',
phone: '2', phone: '2',
monetary: 50, monetary: 50,
@@ -222,6 +224,7 @@ test('getRfmAnalytics groups period buyers by their RFM tag before the selected
recency_days: 164 recency_days: 164
}, },
{ {
customer_key: '3',
name: 'Cliente Medio', name: 'Cliente Medio',
phone: '3', phone: '3',
monetary: 100, monetary: 100,
@@ -229,6 +232,16 @@ test('getRfmAnalytics groups period buyers by their RFM tag before the selected
quantity_purchased: 2, quantity_purchased: 2,
last_purchase_date: '2026-03-01', last_purchase_date: '2026-03-01',
recency_days: 105 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 { return {
rows: [ rows: [
{ {
customer_key: '1',
name: 'Cliente Ontem', name: 'Cliente Ontem',
phone: '1', phone: '1',
monetary: 100, monetary: 100,
@@ -245,12 +259,22 @@ test('getRfmAnalytics groups period buyers by their RFM tag before the selected
last_purchase_date: '2026-06-14' last_purchase_date: '2026-06-14'
}, },
{ {
customer_key: '4',
name: 'Cliente Novo no Periodo', name: 'Cliente Novo no Periodo',
phone: '4', phone: '4',
monetary: 25, monetary: 25,
frequency: 1, frequency: 1,
quantity_purchased: 1, quantity_purchased: 1,
last_purchase_date: '2026-06-14' 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' }); const yesterday = await getRfmAnalytics({ start: '2026-06-14', end: '2026-06-14' });
assert.deepEqual(calls[0].params, ['2026-06-09', '2026-06-15']); 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, /\(\$1::date - MAX\(data_pedido_date\)\)::int/);
assert.match(calls[1].sql, /data_pedido_date <= \$1::date/); 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[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(sevenDays.clients[0].segmentKey, 'champions');
assert.equal(yesterday.clients[0].segmentKey, 'champions'); assert.equal(yesterday.clients[0].segmentKey, 'champions');
assert.equal(yesterday.clients[0].frequency, 1); assert.equal(yesterday.clients[0].frequency, 1);
assert.equal(yesterday.clients[0].monetary, 100); 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 === '2'));
assert.ok(yesterday.clients.some(client => client.phone === '4' && client.segmentKey === 'new_customers')); 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 { } finally {
pool.query = originalQuery; pool.query = originalQuery;
} }

View File

@@ -13,6 +13,7 @@ export type ClientSortOption =
| 'items_asc'; | 'items_asc';
export interface ClientSummary { export interface ClientSummary {
customerKey: string;
name: string; name: string;
phone: string; phone: string;
totalSpent: number; totalSpent: number;
@@ -113,6 +114,7 @@ export const buildClientsSummary = (
): ClientSummary[] => { ): ClientSummary[] => {
const orders = filterOrdersByDateRange(ordersData, dateRange); const orders = filterOrdersByDateRange(ordersData, dateRange);
const clientMap: Record<string, { const clientMap: Record<string, {
name: string;
totalSpent: number; totalSpent: number;
totalItems: number; totalItems: number;
uniqueOrders: Set<string>; uniqueOrders: Set<string>;
@@ -122,33 +124,35 @@ export const buildClientsSummary = (
orders.forEach(order => { orders.forEach(order => {
const clientName = getClientDisplayName(order); const clientName = getClientDisplayName(order);
const customerKey = order.Fone_Cliente || `name:${clientName}`;
if (!clientMap[clientName]) { if (!clientMap[customerKey]) {
clientMap[clientName] = { totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0, phone: '' }; clientMap[customerKey] = { name: clientName, totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0, phone: '' };
} }
if (order.Fone_Cliente) { if (order.Fone_Cliente) {
clientMap[clientName].phone = order.Fone_Cliente; clientMap[customerKey].phone = order.Fone_Cliente;
} }
clientMap[clientName].totalSpent += getOrderItemRevenue(order); clientMap[customerKey].totalSpent += getOrderItemRevenue(order);
clientMap[clientName].totalItems += order.Quantidade; clientMap[customerKey].totalItems += order.Quantidade;
clientMap[clientName].uniqueOrders.add(`${order.Data_Pedido}_${order.Valor_Pedido}`); clientMap[customerKey].uniqueOrders.add(`${order.Data_Pedido}_${order.Valor_Pedido}`);
const orderTime = parseOrderDate(order.Data_Pedido).getTime(); const orderTime = parseOrderDate(order.Data_Pedido).getTime();
if (orderTime > clientMap[clientName].lastPurchase) { if (orderTime > clientMap[customerKey].lastPurchase) {
clientMap[clientName].lastPurchase = orderTime; clientMap[customerKey].lastPurchase = orderTime;
} }
}); });
const normalizedSearch = searchTerm.trim().toLowerCase(); const normalizedSearch = searchTerm.trim().toLowerCase();
const clients = enrichClientsWithRfmType(Object.keys(clientMap).map(name => ({ const clients = enrichClientsWithRfmType(Object.keys(clientMap).map(customerKey => ({
name, customerKey,
phone: clientMap[name].phone, name: clientMap[customerKey].name,
totalSpent: clientMap[name].totalSpent, phone: clientMap[customerKey].phone,
totalItems: clientMap[name].totalItems, totalSpent: clientMap[customerKey].totalSpent,
orderCount: clientMap[name].uniqueOrders.size, totalItems: clientMap[customerKey].totalItems,
lastPurchase: clientMap[name].lastPurchase orderCount: clientMap[customerKey].uniqueOrders.size,
lastPurchase: clientMap[customerKey].lastPurchase
})), dateRange); })), dateRange);
const filteredClients = normalizedSearch const filteredClients = normalizedSearch

View File

@@ -102,10 +102,11 @@ const Clients = () => {
const allClientsData = useMemo(() => { const allClientsData = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase(); 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 clients = clientAnalytics.map((client): ClientSummary => {
const rfmClient = client.phone ? rfmByPhone.get(client.phone) : undefined; const rfmClient = rfmByCustomerKey.get(client.customerKey);
return { return {
customerKey: client.customerKey,
name: client.name, name: client.name,
phone: client.phone, phone: client.phone,
totalSpent: client.totalSpent, totalSpent: client.totalSpent,
@@ -281,7 +282,7 @@ const Clients = () => {
</thead> </thead>
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border"> <tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
{paginatedData.map((client, index) => ( {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"> <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"> <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} {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"> <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> <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> <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>
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm"> <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> <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 className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<h2 className="text-lg font-bold text-dark-text">Matriz RFV</h2> <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>
<div className="flex items-center gap-2 text-xs font-semibold text-dark-muted"> <div className="flex items-center gap-2 text-xs font-semibold text-dark-muted">
<span>Menor prioridade</span> <span>Menor prioridade</span>
@@ -531,7 +531,7 @@ const Rfm = () => {
{paginatedClients.map((client: RfmClient) => { {paginatedClients.map((client: RfmClient) => {
const style = segmentStyles[client.segmentKey] || segmentStyles.lost; const style = segmentStyles[client.segmentKey] || segmentStyles.lost;
return ( 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"> <td className="px-6 py-3">
<Link to={`/clients/${encodeURIComponent(client.name)}`} className="flex items-center gap-3 hover:text-brand-primary transition-colors"> <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"> <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 { export interface ClientAnalyticsItem {
customerKey: string;
name: string; name: string;
phone: string; phone: string;
quantityPurchased: number; quantityPurchased: number;
@@ -73,6 +74,7 @@ export interface ClientAnalyticsItem {
} }
export interface RfmClient { export interface RfmClient {
customerKey: string;
name: string; name: string;
phone: string; phone: string;
monetary: number; monetary: number;