Add RFM segmentation analytics
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m12s

This commit is contained in:
Cauê Faleiros
2026-06-12 11:22:57 -03:00
parent 25ab6cd448
commit fe8d0ff105
10 changed files with 1231 additions and 14 deletions

View File

@@ -2,15 +2,27 @@ import type { DateRange, OrderData } from '../types';
import { parseOrderDate } from '../dataService';
import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue } from './orders';
export type ClientSortOption = 'recent' | 'spent_desc' | 'spent_asc' | 'items_desc' | 'items_asc';
export type ClientSortOption =
| 'recent'
| 'spent_desc'
| 'spent_asc'
| 'ticket_desc'
| 'ticket_asc'
| 'rfm_priority'
| 'items_desc'
| 'items_asc';
export interface ClientSummary {
name: string;
phone: string;
totalSpent: number;
averageTicket: number;
totalItems: number;
orderCount: number;
lastPurchase: number;
clientType: string;
rfmScore: string;
rfmPriority: number;
}
export interface GroupedClientOrder {
@@ -27,6 +39,64 @@ export interface ClientDetailsMetrics {
clientPhone: string;
}
const scoreTertile = (value: number, values: number[], higherIsBetter = true) => {
const numericValues = values.filter(Number.isFinite);
if (!numericValues.length) return 1;
if (numericValues.length === 1) return 3;
const min = Math.min(...numericValues);
const max = Math.max(...numericValues);
if (min === max) return 2;
const sorted = [...numericValues].sort((a, b) => higherIsBetter ? a - b : b - a);
const index = sorted.findIndex(candidate => candidate === value);
const percentile = index / (sorted.length - 1);
return Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1));
};
const getClientType = (recencyScore: number, valueScore: number) => {
const segmentMap: Record<string, string> = {
'3-3': 'Campeão',
'3-2': 'Potencial Leal',
'3-1': 'Novo Cliente',
'2-3': 'Cliente Leal',
'2-2': 'Precisa de Atenção',
'2-1': 'Quase Dormindo',
'1-3': 'Em Risco',
'1-2': 'Hibernando',
'1-1': 'Perdido'
};
return segmentMap[`${recencyScore}-${valueScore}`] || 'Perdido';
};
const enrichClientsWithRfmType = (
clients: Array<Omit<ClientSummary, 'averageTicket' | 'clientType' | 'rfmScore' | 'rfmPriority'>>,
dateRange: DateRange
): ClientSummary[] => {
const rangeEndTime = dateRange.end.getTime();
const recencyValues = clients.map(client => Math.max(0, Math.floor((rangeEndTime - client.lastPurchase) / 86400000)));
const frequencyValues = clients.map(client => client.orderCount);
const monetaryValues = clients.map(client => client.totalSpent);
return clients.map((client, index) => {
const recencyScore = scoreTertile(recencyValues[index], recencyValues, false);
const frequencyScore = scoreTertile(client.orderCount, frequencyValues);
const monetaryScore = scoreTertile(client.totalSpent, monetaryValues);
const valueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2)));
const rfmPriority = (recencyScore * 100) + (valueScore * 10) + monetaryScore;
return {
...client,
averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0,
clientType: getClientType(recencyScore, valueScore),
rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`,
rfmPriority
};
});
};
export const buildClientsSummary = (
ordersData: OrderData[],
dateRange: DateRange,
@@ -64,14 +134,14 @@ export const buildClientsSummary = (
});
const normalizedSearch = searchTerm.trim().toLowerCase();
const clients = Object.keys(clientMap).map(name => ({
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
}));
})), dateRange);
const filteredClients = normalizedSearch
? clients.filter(client => client.name.toLowerCase().includes(normalizedSearch))
@@ -82,6 +152,9 @@ export const buildClientsSummary = (
case 'recent': return b.lastPurchase - a.lastPurchase;
case 'spent_desc': return b.totalSpent - a.totalSpent;
case 'spent_asc': return a.totalSpent - b.totalSpent;
case 'ticket_desc': return b.averageTicket - a.averageTicket;
case 'ticket_asc': return a.averageTicket - b.averageTicket;
case 'rfm_priority': return b.rfmPriority - a.rfmPriority;
case 'items_desc': return b.totalItems - a.totalItems;
case 'items_asc': return a.totalItems - b.totalItems;
default: return 0;