Files
graphs/src/analytics/clients.ts
Cauê Faleiros a0a1108f3a
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m25s
Use all-time client purchase patterns
2026-06-29 09:29:16 -03:00

298 lines
9.9 KiB
TypeScript

import type { DateRange, OrderData } from '../types.ts';
import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue, getOrderCustomerKey, parseOrderDate } from './orders.ts';
export type ClientSortOption =
| 'recent'
| 'spent_desc'
| 'spent_asc'
| 'ticket_desc'
| 'ticket_asc'
| 'rfm_priority'
| 'items_desc'
| 'items_asc';
export interface ClientSummary {
customerKey: string;
clientToken: string;
name: string;
phone: string;
totalSpent: number;
averageTicket: number;
totalItems: number;
orderCount: number;
lastPurchase: number;
clientType: string;
rfmScore: string;
rfmPriority: number;
}
export interface GroupedClientOrder {
date: string;
orderId: string;
orderTotal: number;
items: OrderData[];
}
export interface ClientDetailsMetrics {
chartData: Array<{
date: string;
value: number;
}>;
purchaseWeekdays: Array<{
label: string;
value: number;
}>;
purchaseHours: Array<{
label: string;
value: number;
}>;
groupedOrders: GroupedClientOrder[];
allTimeOrderCount: number;
clientName: string;
clientPhone: string;
hasClient: boolean;
periodAverageTicket: number;
periodOrderCount: number;
periodSpent: number;
periodItems: number;
}
const WEEKDAY_LABELS = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab'];
const getOrderHour = (order: OrderData): number | null => {
const timestamp = order.Recebido_Em || (/\d{1,2}:\d{2}/.test(order.Data_Pedido || '') ? order.Data_Pedido : '');
if (!timestamp) return null;
const date = new Date(timestamp);
if (!Number.isNaN(date.getTime())) return date.getHours();
const timeMatch = String(timestamp).match(/\b(\d{1,2}):\d{2}/);
if (!timeMatch) return null;
const hour = Number(timeMatch[1]);
return hour >= 0 && hour <= 23 ? hour : null;
};
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,
clientToken: client.clientToken || client.customerKey,
averageTicket: client.orderCount ? client.totalSpent / client.orderCount : 0,
clientType: getClientType(recencyScore, valueScore),
rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`,
rfmPriority
};
});
};
export const buildClientsSummary = (
ordersData: OrderData[],
dateRange: DateRange,
searchTerm: string,
sortBy: ClientSortOption
): ClientSummary[] => {
const orders = filterOrdersByDateRange(ordersData, dateRange);
const clientMap: Record<string, {
name: string;
totalSpent: number;
totalItems: number;
uniqueOrders: Set<string>;
lastPurchase: number;
phone: string;
}> = {};
orders.forEach(order => {
const clientName = getClientDisplayName(order);
const customerKey = getOrderCustomerKey(order);
if (!clientMap[customerKey]) {
clientMap[customerKey] = { name: clientName, totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0, phone: '' };
}
if (order.Fone_Cliente) {
clientMap[customerKey].phone = order.Fone_Cliente;
}
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[customerKey].lastPurchase) {
clientMap[customerKey].lastPurchase = orderTime;
}
});
const normalizedSearch = searchTerm.trim().toLowerCase();
const clients = enrichClientsWithRfmType(Object.keys(clientMap).map(customerKey => ({
customerKey,
clientToken: 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
? clients.filter(client => client.name.toLowerCase().includes(normalizedSearch))
: clients;
return filteredClients.sort((a, b) => {
switch (sortBy) {
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;
}
});
};
export const buildClientDetailsMetrics = (ordersData: OrderData[], customerKey: string, dateRange: DateRange): ClientDetailsMetrics => {
const keyedOrders = ordersData.filter(order => getOrderCustomerKey(order) === customerKey);
const legacyNameOrders = keyedOrders.length
? []
: ordersData.filter(order => getClientDisplayName(order) === customerKey);
const legacyCustomerKeys = new Set(legacyNameOrders.map(getOrderCustomerKey));
const resolvedLegacyKey = legacyCustomerKeys.size === 1 ? [...legacyCustomerKeys][0] : '';
const clientOrders = keyedOrders.length
? keyedOrders
: resolvedLegacyKey
? ordersData.filter(order => getOrderCustomerKey(order) === resolvedLegacyKey)
: [];
const periodOrders = filterOrdersByDateRange(clientOrders, dateRange);
const groupedOrdersMap: Record<string, GroupedClientOrder> = {};
const spentByDate: Record<string, number> = {};
let clientPhone = '';
let clientName = '';
let periodSpent = 0;
let periodItems = 0;
clientOrders.forEach(order => {
if (order.Fone_Cliente && !clientPhone) clientPhone = order.Fone_Cliente;
if (!clientName) clientName = getClientDisplayName(order);
});
periodOrders.forEach(order => {
periodSpent += getOrderItemRevenue(order);
periodItems += order.Quantidade;
spentByDate[order.Data_Pedido] = (spentByDate[order.Data_Pedido] || 0) + getOrderItemRevenue(order);
const key = order.ID_Pedido || `${order.Data_Pedido}_${order.Valor_Pedido}`;
if (!groupedOrdersMap[key]) {
groupedOrdersMap[key] = {
date: order.Data_Pedido,
orderId: order.ID_Pedido || key,
orderTotal: 0,
items: []
};
}
groupedOrdersMap[key].items.push(order);
groupedOrdersMap[key].orderTotal += getOrderItemRevenue(order);
});
const groupedOrders = Object.values(groupedOrdersMap).sort((a, b) => {
return parseOrderDate(b.date).getTime() - parseOrderDate(a.date).getTime();
});
const allTimeOrderIds = new Set(clientOrders.map(order => order.ID_Pedido || `${order.Data_Pedido}_${order.Valor_Pedido}`));
const periodOrderCount = groupedOrders.length;
const chartData = Object.keys(spentByDate).map(date => ({
date,
value: spentByDate[date]
})).sort((a, b) => parseOrderDate(a.date).getTime() - parseOrderDate(b.date).getTime());
const weekdayCounts = WEEKDAY_LABELS.map(label => ({ label, value: 0 }));
const hourCounts = Array.from({ length: 24 }, (_, hour) => ({
label: `${String(hour).padStart(2, '0')}h`,
value: 0
}));
const patternOrders = new Map<string, OrderData>();
clientOrders.forEach(order => {
const key = order.ID_Pedido || `${order.Data_Pedido}_${order.Valor_Pedido}`;
if (!patternOrders.has(key)) {
patternOrders.set(key, order);
}
});
patternOrders.forEach(order => {
const orderDate = parseOrderDate(order.Data_Pedido);
if (!Number.isNaN(orderDate.getTime())) {
weekdayCounts[orderDate.getDay()].value += 1;
}
const orderHour = getOrderHour(order);
if (orderHour !== null) {
hourCounts[orderHour].value += 1;
}
});
return {
chartData,
purchaseWeekdays: weekdayCounts,
purchaseHours: hourCounts,
groupedOrders,
allTimeOrderCount: allTimeOrderIds.size,
clientName,
clientPhone,
hasClient: clientOrders.length > 0,
periodAverageTicket: periodOrderCount ? periodSpent / periodOrderCount : 0,
periodOrderCount,
periodSpent,
periodItems
};
};