All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m25s
298 lines
9.9 KiB
TypeScript
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
|
|
};
|
|
};
|