Fix client detail customer identity
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m17s

This commit is contained in:
Cauê Faleiros
2026-06-18 16:31:22 -03:00
parent 1af624aa77
commit c35f8f8a51
8 changed files with 173 additions and 36 deletions

View File

@@ -0,0 +1,119 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DateRange, OrderData } from '../types.ts';
import { buildClientDetailsMetrics } from './clients.ts';
const allPeriod: DateRange = {
start: new Date(2000, 0, 1),
end: new Date(2026, 5, 18, 23, 59, 59, 999)
};
const order = (overrides: Partial<OrderData>): OrderData => ({
Nome_Cliente: 'Cliente',
Data_Pedido: '01-06-2026',
Valor_Pedido: 0,
ID_Produto: '1',
Descricao_Produto: 'Produto',
Quantidade: 1,
Valor_Unitario: 10,
ID_Pedido: 'pedido',
Fone_Cliente: '',
...overrides
});
test('client details do not merge different phone keys that share the same name', () => {
const orders = [
order({
Nome_Cliente: 'Leonardo Barbosa',
Fone_Cliente: '111',
ID_Pedido: 'pedido-1',
Quantidade: 2,
Valor_Unitario: 10
}),
order({
Nome_Cliente: 'Leonardo Barbosa',
Fone_Cliente: '222',
ID_Pedido: 'pedido-2',
Quantidade: 5,
Valor_Unitario: 10
})
];
const metrics = buildClientDetailsMetrics(orders, '111', allPeriod);
assert.equal(metrics.periodOrderCount, 1);
assert.equal(metrics.periodItems, 2);
assert.equal(metrics.periodSpent, 20);
assert.equal(metrics.clientPhone, '111');
});
test('legacy name detail URLs reject ambiguous names instead of merging customers', () => {
const orders = [
order({
Nome_Cliente: 'Leonardo Barbosa',
Fone_Cliente: '111',
ID_Pedido: 'pedido-1'
}),
order({
Nome_Cliente: 'Leonardo Barbosa',
Fone_Cliente: '222',
ID_Pedido: 'pedido-2'
})
];
const metrics = buildClientDetailsMetrics(orders, 'Leonardo Barbosa', allPeriod);
assert.equal(metrics.hasClient, false);
assert.equal(metrics.periodOrderCount, 0);
assert.equal(metrics.periodItems, 0);
});
test('client details merge name variations that share the same phone key', () => {
const orders = [
order({
Nome_Cliente: 'Jose de Matos Dreher',
Fone_Cliente: '333',
ID_Pedido: 'pedido-1',
Valor_Unitario: 10
}),
order({
Nome_Cliente: 'José Matos',
Fone_Cliente: '333',
ID_Pedido: 'pedido-2',
Valor_Unitario: 20
})
];
const metrics = buildClientDetailsMetrics(orders, '333', allPeriod);
assert.equal(metrics.periodOrderCount, 2);
assert.equal(metrics.periodItems, 2);
assert.equal(metrics.periodSpent, 30);
});
test('client detail order totals use the same line-item revenue as summary metrics', () => {
const orders = [
order({
Nome_Cliente: 'Cliente Sem Fone',
ID_Pedido: 'pedido-1',
ID_Produto: 'produto-1',
Valor_Pedido: 100,
Quantidade: 2,
Valor_Unitario: 10
}),
order({
Nome_Cliente: 'Cliente Sem Fone',
ID_Pedido: 'pedido-1',
ID_Produto: 'produto-2',
Valor_Pedido: 100,
Quantidade: 1,
Valor_Unitario: 5
})
];
const metrics = buildClientDetailsMetrics(orders, 'name:Cliente Sem Fone', allPeriod);
assert.equal(metrics.periodSpent, 25);
assert.equal(metrics.groupedOrders[0].orderTotal, 25);
});

View File

@@ -1,6 +1,5 @@
import type { DateRange, OrderData } from '../types';
import { parseOrderDate } from '../dataService';
import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue } from './orders';
import type { DateRange, OrderData } from '../types.ts';
import { filterOrdersByDateRange, getClientDisplayName, getOrderItemRevenue, getOrderCustomerKey, parseOrderDate } from './orders.ts';
export type ClientSortOption =
| 'recent'
@@ -40,6 +39,7 @@ export interface ClientDetailsMetrics {
}>;
groupedOrders: GroupedClientOrder[];
allTimeOrderCount: number;
clientName: string;
clientPhone: string;
hasClient: boolean;
periodAverageTicket: number;
@@ -124,7 +124,7 @@ export const buildClientsSummary = (
orders.forEach(order => {
const clientName = getClientDisplayName(order);
const customerKey = order.Fone_Cliente || `name:${clientName}`;
const customerKey = getOrderCustomerKey(order);
if (!clientMap[customerKey]) {
clientMap[customerKey] = { name: clientName, totalSpent: 0, totalItems: 0, uniqueOrders: new Set(), lastPurchase: 0, phone: '' };
@@ -174,17 +174,29 @@ export const buildClientsSummary = (
});
};
export const buildClientDetailsMetrics = (ordersData: OrderData[], clientName: string, dateRange: DateRange): ClientDetailsMetrics => {
const clientOrders = ordersData.filter(order => getClientDisplayName(order) === clientName);
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 => {
@@ -197,11 +209,12 @@ export const buildClientDetailsMetrics = (ordersData: OrderData[], clientName: s
groupedOrdersMap[key] = {
date: order.Data_Pedido,
orderId: order.ID_Pedido || key,
orderTotal: order.Valor_Pedido,
orderTotal: 0,
items: []
};
}
groupedOrdersMap[key].items.push(order);
groupedOrdersMap[key].orderTotal += getOrderItemRevenue(order);
});
const groupedOrders = Object.values(groupedOrdersMap).sort((a, b) => {
@@ -219,6 +232,7 @@ export const buildClientDetailsMetrics = (ordersData: OrderData[], clientName: s
chartData,
groupedOrders,
allTimeOrderCount: allTimeOrderIds.size,
clientName,
clientPhone,
hasClient: clientOrders.length > 0,
periodAverageTicket: periodOrderCount ? periodSpent / periodOrderCount : 0,

View File

@@ -1,8 +1,21 @@
import type { DateRange, OrderData } from '../types';
import { parseOrderDate } from '../dataService';
import type { DateRange, OrderData } from '../types.ts';
const SIZE_SUFFIX_PATTERN = /\s+-\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2})(?:\/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\d{2}))*)$/i;
export const parseOrderDate = (dateStr: string): Date => {
if (!dateStr) return new Date(0);
if (dateStr.includes('T')) return new Date(dateStr);
const parts = dateStr.split(/[-/]/);
if (parts.length === 3) {
if (parts[0].length === 4) {
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
}
return new Date(Number(parts[2]), Number(parts[1]) - 1, Number(parts[0]));
}
const fallback = new Date(dateStr);
return Number.isNaN(fallback.getTime()) ? new Date(0) : fallback;
};
export const getBaseProductName = (description: string): string => {
const productName = description.trim();
if (productName.toLocaleUpperCase('pt-BR').startsWith('ETIQUETA')) {
@@ -13,7 +26,11 @@ export const getBaseProductName = (description: string): string => {
};
export const getClientDisplayName = (order: OrderData): string => {
return order.Nome_Cliente || `Cliente Desconhecido (Pedido ${order.Valor_Pedido})`;
return order.Nome_Cliente || 'Cliente Desconhecido';
};
export const getOrderCustomerKey = (order: OrderData): string => {
return order.Fone_Cliente || `name:${getClientDisplayName(order)}`;
};
export const isOrderInDateRange = (order: OrderData, dateRange: DateRange): boolean => {