Fix analytics date fallback and RFV scoring
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m7s

This commit is contained in:
Cauê Faleiros
2026-06-17 12:04:58 -03:00
parent f88ae2d491
commit aedfedca76
6 changed files with 131 additions and 150 deletions

View File

@@ -109,46 +109,6 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
return response;
};
const rfmSegmentLabels: Record<string, string> = {
champions: 'Champions',
potential_loyalists: 'Potenciais Leais',
new_customers: 'Novos Clientes',
loyal_customers: 'Clientes Leais',
need_attention: 'Precisam de Atenção',
about_to_sleep: 'Quase Dormindo',
at_risk: 'Em Risco',
hibernating: 'Hibernando',
lost: 'Perdidos'
};
const rfmSegmentByScore: Record<string, string> = {
'3-3': 'champions',
'3-2': 'potential_loyalists',
'3-1': 'new_customers',
'2-3': 'loyal_customers',
'2-2': 'need_attention',
'2-1': 'about_to_sleep',
'1-3': 'at_risk',
'1-2': 'hibernating',
'1-1': 'lost'
};
const scoreTertile = (value: number, values: number[], higherIsBetter = true): 1 | 2 | 3 => {
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 higherIsBetter ? 2 : 3;
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)) as 1 | 2 | 3;
};
const fetchClientAnalyticsForRange = async (dateRange: DateRange): Promise<ClientAnalyticsItem[]> => {
const params = new URLSearchParams({
start: formatDateParam(dateRange.start),
@@ -159,78 +119,6 @@ const fetchClientAnalyticsForRange = async (dateRange: DateRange): Promise<Clien
return await response.json();
};
const buildFallbackRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalytics | null> => {
let clientRows: ClientAnalyticsItem[];
try {
clientRows = await fetchClientAnalyticsForRange(dateRange);
} catch (error) {
console.error('Fetch fallback RFM analytics failed', error);
return null;
}
if (!clientRows.length) return null;
const rangeEndTime = dateRange.end.getTime();
const recencyValues = clientRows.map(client => {
const lastPurchaseTime = client.lastPurchaseDate ? new Date(client.lastPurchaseDate).getTime() : rangeEndTime;
return Math.max(0, Math.floor((rangeEndTime - lastPurchaseTime) / 86400000));
});
const frequencyValues = clientRows.map(client => client.orderCount);
const monetaryValues = clientRows.map(client => client.totalSpent);
const clients = clientRows.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))) as 1 | 2 | 3;
const segmentKey = rfmSegmentByScore[`${recencyScore}-${valueScore}`] || 'lost';
return {
customerKey: client.customerKey,
name: client.name,
phone: client.phone,
monetary: client.totalSpent,
frequency: client.orderCount,
quantityPurchased: client.quantityPurchased,
lastPurchaseDate: client.lastPurchaseDate,
recencyDays: recencyValues[index],
recencyScore,
frequencyScore,
monetaryScore,
valueScore,
rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`,
segmentKey,
segmentLabel: rfmSegmentLabels[segmentKey] || 'Perdidos'
};
});
const segments = Object.entries(rfmSegmentLabels).map(([key, label]) => {
const segmentClients = clients.filter(client => client.segmentKey === key);
const totalRevenue = segmentClients.reduce((sum, client) => sum + client.monetary, 0);
return {
key,
label,
count: segmentClients.length,
totalRevenue,
averageRevenue: segmentClients.length ? totalRevenue / segmentClients.length : 0
};
});
return {
range: {
start: formatDateParam(dateRange.start),
end: formatDateParam(dateRange.end)
},
clients,
segments,
matrix: {
recencyScores: [3, 2, 1],
valueScores: [1, 2, 3]
}
};
};
export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise<DashboardAnalytics | null> => {
try {
const params = new URLSearchParams({
@@ -253,11 +141,11 @@ export const fetchRfmAnalytics = async (dateRange: DateRange): Promise<RfmAnalyt
end: formatDateParam(dateRange.end)
});
const response = await authFetch(`/analytics/rfm?${params.toString()}`);
if (!response.ok) return await buildFallbackRfmAnalytics(dateRange);
if (!response.ok) return null;
return await response.json();
} catch (error) {
console.error('Fetch RFM analytics failed', error);
return await buildFallbackRfmAnalytics(dateRange);
return null;
}
};

View File

@@ -292,7 +292,7 @@ const Rfm = () => {
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<p className="text-dark-muted text-sm font-medium mb-1">Clientes no Período</p>
<h3 className="text-3xl font-bold text-dark-text">{clients.length}</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">Agrupados pela tag RFV anterior</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Segmento RFV calculado até o fim do período</p>
</div>
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<p className="text-dark-muted text-sm font-medium mb-1">Receita no Período</p>
@@ -312,7 +312,7 @@ const Rfm = () => {
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-lg font-bold text-dark-text">Matriz RFV</h2>
<p className="text-sm font-medium text-dark-muted">Compradores do período agrupados pela tag RFV anterior ao período.</p>
<p className="text-sm font-medium text-dark-muted">Compradores do período agrupados pelo RFV histórico até a data final.</p>
</div>
<div className="flex items-center gap-2 text-xs font-semibold text-dark-muted">
<span>Menor prioridade</span>