182 lines
6.0 KiB
TypeScript
182 lines
6.0 KiB
TypeScript
export type DashboardQuote = {
|
|
price: number;
|
|
carrierName: string;
|
|
};
|
|
|
|
export type DashboardSimulation = {
|
|
quotedPrice: number | null;
|
|
distanceKm: number | null;
|
|
bestCarrierName: string | null;
|
|
originLabel: string | null;
|
|
destinationLabel: string | null;
|
|
originZip: string;
|
|
destinationZip: string;
|
|
quotes: DashboardQuote[];
|
|
createdAt: Date | string;
|
|
};
|
|
|
|
export type DashboardPayload = {
|
|
periodDays: number;
|
|
kpis: {
|
|
simulations: { value: number; deltaPct: number | null };
|
|
averageCost: { value: number | null; deltaPct: number | null };
|
|
costPerKm: { value: number | null; deltaPct: number | null };
|
|
leftoverSavings: { value: number; sharePct: number | null };
|
|
};
|
|
insights: string[];
|
|
carriers: { name: string; wins: number; sharePct: number; averageWin: number }[];
|
|
routes: { label: string; count: number; averageCost: number }[];
|
|
customers: number;
|
|
activeCarriers: number;
|
|
};
|
|
|
|
function round2(value: number) {
|
|
return Math.round(value * 100) / 100;
|
|
}
|
|
|
|
function average(values: number[]) {
|
|
if (!values.length) {
|
|
return null;
|
|
}
|
|
return round2(values.reduce((sum, value) => sum + value, 0) / values.length);
|
|
}
|
|
|
|
function deltaPct(current: number | null, previous: number | null) {
|
|
if (current === null || previous === null || previous === 0) {
|
|
return null;
|
|
}
|
|
return round2(((current - previous) / previous) * 100);
|
|
}
|
|
|
|
function money(value: number) {
|
|
return value.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
|
}
|
|
|
|
function pricesOf(sim: DashboardSimulation) {
|
|
if (sim.quotedPrice !== null) {
|
|
return [sim.quotedPrice];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function costPerKmValues(rows: DashboardSimulation[]) {
|
|
return rows
|
|
.filter((row) => row.quotedPrice !== null && row.distanceKm !== null && row.distanceKm > 0)
|
|
.map((row) => (row.quotedPrice as number) / (row.distanceKm as number));
|
|
}
|
|
|
|
function leftoverOf(sim: DashboardSimulation) {
|
|
const prices = sim.quotes.map((quote) => quote.price).filter((price) => price > 0);
|
|
if (prices.length < 2) {
|
|
return 0;
|
|
}
|
|
return Math.max(...prices) - Math.min(...prices);
|
|
}
|
|
|
|
function routeKey(sim: DashboardSimulation) {
|
|
const origin = sim.originLabel || sim.originZip;
|
|
const destination = sim.destinationLabel || sim.destinationZip;
|
|
return `${origin} → ${destination}`;
|
|
}
|
|
|
|
export function buildDashboard(input: {
|
|
current: DashboardSimulation[];
|
|
previous: DashboardSimulation[];
|
|
customerCount: number;
|
|
carrierCount: number;
|
|
periodDays?: number;
|
|
}): DashboardPayload {
|
|
const periodDays = input.periodDays ?? 30;
|
|
const currentCosts = input.current.flatMap(pricesOf);
|
|
const previousCosts = input.previous.flatMap(pricesOf);
|
|
const averageCost = average(currentCosts);
|
|
const previousAverage = average(previousCosts);
|
|
const costPerKm = average(costPerKmValues(input.current));
|
|
const previousCostPerKm = average(costPerKmValues(input.previous));
|
|
const leftoverSavings = round2(input.current.reduce((sum, sim) => sum + leftoverOf(sim), 0));
|
|
const comparable = input.current.filter((sim) => leftoverOf(sim) > 0).length;
|
|
const sharePct = input.current.length ? round2((comparable / input.current.length) * 100) : null;
|
|
|
|
const wins = new Map<string, { wins: number; total: number }>();
|
|
for (const sim of input.current) {
|
|
if (!sim.bestCarrierName || sim.quotedPrice === null) {
|
|
continue;
|
|
}
|
|
const row = wins.get(sim.bestCarrierName) ?? { wins: 0, total: 0 };
|
|
row.wins += 1;
|
|
row.total += sim.quotedPrice;
|
|
wins.set(sim.bestCarrierName, row);
|
|
}
|
|
const winTotal = [...wins.values()].reduce((sum, row) => sum + row.wins, 0);
|
|
const carriers = [...wins.entries()]
|
|
.map(([name, row]) => ({
|
|
name,
|
|
wins: row.wins,
|
|
sharePct: winTotal ? round2((row.wins / winTotal) * 100) : 0,
|
|
averageWin: round2(row.total / row.wins),
|
|
}))
|
|
.sort((a, b) => b.wins - a.wins);
|
|
|
|
const routesMap = new Map<string, { count: number; total: number }>();
|
|
for (const sim of input.current) {
|
|
if (sim.quotedPrice === null) {
|
|
continue;
|
|
}
|
|
const key = routeKey(sim);
|
|
const row = routesMap.get(key) ?? { count: 0, total: 0 };
|
|
row.count += 1;
|
|
row.total += sim.quotedPrice;
|
|
routesMap.set(key, row);
|
|
}
|
|
const routes = [...routesMap.entries()]
|
|
.map(([label, row]) => ({
|
|
label,
|
|
count: row.count,
|
|
averageCost: round2(row.total / row.count),
|
|
}))
|
|
.sort((a, b) => b.averageCost - a.averageCost)
|
|
.slice(0, 5);
|
|
|
|
const insights: string[] = [];
|
|
const costDelta = deltaPct(averageCost, previousAverage);
|
|
if (costDelta !== null && costDelta > 0) {
|
|
insights.push(`O custo médio da melhor cotação subiu ${costDelta}% frente aos ${periodDays} dias anteriores.`);
|
|
} else if (costDelta !== null && costDelta < 0) {
|
|
insights.push(`O custo médio da melhor cotação caiu ${Math.abs(costDelta)}% frente aos ${periodDays} dias anteriores.`);
|
|
}
|
|
if (leftoverSavings > 0) {
|
|
insights.push(
|
|
`A diferença entre a pior e a melhor opção soma ${money(leftoverSavings)} no período. Comparar cotações evita pagar o teto.`,
|
|
);
|
|
}
|
|
const leader = carriers[0];
|
|
if (leader && leader.sharePct >= 50) {
|
|
insights.push(
|
|
`${leader.name} venceu ${leader.sharePct}% das simulações. Concentração alta: negocie prazo e dependência.`,
|
|
);
|
|
} else if (leader) {
|
|
insights.push(`${leader.name} lidera as melhores cotações (${leader.sharePct}%). Vale cruzar com prazo, não só preço.`);
|
|
}
|
|
if (!input.current.length) {
|
|
insights.push('Ainda não há simulações neste período. As primeiras cotações passam a alimentar custo, rota e dependência.');
|
|
}
|
|
|
|
return {
|
|
periodDays,
|
|
kpis: {
|
|
simulations: {
|
|
value: input.current.length,
|
|
deltaPct: deltaPct(input.current.length || null, input.previous.length || null),
|
|
},
|
|
averageCost: { value: averageCost, deltaPct: costDelta },
|
|
costPerKm: { value: costPerKm, deltaPct: deltaPct(costPerKm, previousCostPerKm) },
|
|
leftoverSavings: { value: leftoverSavings, sharePct },
|
|
},
|
|
insights,
|
|
carriers,
|
|
routes,
|
|
customers: input.customerCount,
|
|
activeCarriers: input.carrierCount,
|
|
};
|
|
}
|