Add RFM segmentation analytics
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m12s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m12s
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user