799 lines
24 KiB
TypeScript
799 lines
24 KiB
TypeScript
import { prisma, redis } from "../../constants";
|
||
import { events } from "../globalEvents";
|
||
import { opportunities } from "../../managers/opportunities";
|
||
import { normalizeProbabilityRatio } from "../sales-utils/normalizeProbability";
|
||
|
||
const METRICS_CACHE_TTL_MS = 10 * 60 * 1000;
|
||
const ALL_MEMBERS_KEY = "sales:metrics:members:all";
|
||
const MEMBER_KEY_PREFIX = "sales:metrics:member:";
|
||
const OPP_REVENUE_KEY_PREFIX = "sales:metrics:oppRevenue:";
|
||
const PRODUCT_FETCH_CONCURRENCY = 6;
|
||
const PRODUCT_LOOKUP_TIMEOUT_MS = 35_000;
|
||
const LOG_PREFIX = "[cache:salesMetrics]";
|
||
|
||
const log = (message: string) => {
|
||
const ts = new Date().toISOString();
|
||
console.log(`${LOG_PREFIX} ${ts} ${message}`);
|
||
};
|
||
|
||
let salesMetricsRefreshInFlight: Promise<void> | null = null;
|
||
|
||
const memberKey = (identifier: string) =>
|
||
`${MEMBER_KEY_PREFIX}${identifier.toLowerCase()}`;
|
||
const oppRevenueKey = (cwOpportunityId: number) =>
|
||
`${OPP_REVENUE_KEY_PREFIX}${cwOpportunityId}`;
|
||
|
||
const deleteKeysByPrefix = async (prefix: string) => {
|
||
const keys = await redis.keys(`${prefix}*`);
|
||
if (keys.length === 0) return 0;
|
||
|
||
await redis.del(...keys);
|
||
return keys.length;
|
||
};
|
||
|
||
export interface OpportunityBreakdownEntry {
|
||
id: string;
|
||
cwId: number;
|
||
name: string;
|
||
revenue: number;
|
||
taxableRevenue: number;
|
||
nonTaxableRevenue: number;
|
||
/** Probability as a 0–100 percent value */
|
||
probability: number;
|
||
weightedRevenue: number;
|
||
closedDate: string | null;
|
||
}
|
||
|
||
export interface MemberSalesMetrics {
|
||
memberIdentifier: string;
|
||
memberName: string;
|
||
generatedAt: string;
|
||
pipelineRevenue: number;
|
||
closedWonRevenueMtd: number;
|
||
closedWonRevenueYtd: number;
|
||
winCount: { mtd: number; ytd: number };
|
||
lossCount: { mtd: number; ytd: number };
|
||
avgDaysToClose: number;
|
||
openOpportunityCount: number;
|
||
wonOpportunityCount: { mtd: number; ytd: number };
|
||
lostOpportunityCount: { mtd: number; ytd: number };
|
||
closedOpportunityCount: { mtd: number; ytd: number };
|
||
weightedPipelineRevenue: number;
|
||
taxablePipelineRevenue: number;
|
||
nonTaxablePipelineRevenue: number;
|
||
avgOpenDealSize: number;
|
||
avgWonDealSize: { mtd: number; ytd: number };
|
||
winRate: { mtd: number; ytd: number };
|
||
lossRate: { mtd: number; ytd: number };
|
||
assignedOpportunityCount: number;
|
||
cacheHitCount: number;
|
||
cacheMissCount: number;
|
||
cacheHitRate: number;
|
||
opportunityBreakdown: {
|
||
pipeline: OpportunityBreakdownEntry[];
|
||
closedWonMtd: OpportunityBreakdownEntry[];
|
||
closedWonYtd: OpportunityBreakdownEntry[];
|
||
closedLostMtd: OpportunityBreakdownEntry[];
|
||
closedLostYtd: OpportunityBreakdownEntry[];
|
||
};
|
||
}
|
||
|
||
export interface SalesMetricsCacheEnvelope {
|
||
generatedAt: string;
|
||
activeMemberCount: number;
|
||
memberIdentifiers: string[];
|
||
members: Record<string, MemberSalesMetrics>;
|
||
}
|
||
|
||
interface OpportunityRevenue {
|
||
totalRevenue: number;
|
||
taxableRevenue: number;
|
||
nonTaxableRevenue: number;
|
||
cacheHit: boolean;
|
||
}
|
||
|
||
interface CachedOpportunityRevenue {
|
||
totalRevenue: number;
|
||
taxableRevenue: number;
|
||
nonTaxableRevenue: number;
|
||
}
|
||
|
||
interface OpportunityRow {
|
||
id: number;
|
||
uid: string;
|
||
name: string;
|
||
primarySalesRepId: string | null;
|
||
secondarySalesRepId: string | null;
|
||
status: {
|
||
wonFlag: boolean;
|
||
lostFlag: boolean;
|
||
closeFlag: boolean;
|
||
} | null;
|
||
closedFlag: boolean;
|
||
dateBecameLead: Date | null;
|
||
closedDate: Date | null;
|
||
probability: number;
|
||
}
|
||
|
||
interface RefreshSalesOpportunityMetricsCacheOptions {
|
||
forceColdLoad?: boolean;
|
||
}
|
||
|
||
const roundCurrency = (value: number) => Math.round(value * 100) / 100;
|
||
|
||
const daysBetween = (start: Date, end: Date): number => {
|
||
const msPerDay = 1000 * 60 * 60 * 24;
|
||
return Math.max(0, (end.getTime() - start.getTime()) / msPerDay);
|
||
};
|
||
|
||
const startOfMonthUtc = (input: Date): Date =>
|
||
new Date(Date.UTC(input.getUTCFullYear(), input.getUTCMonth(), 1, 0, 0, 0));
|
||
|
||
const startOfYearUtc = (input: Date): Date =>
|
||
new Date(Date.UTC(input.getUTCFullYear(), 0, 1, 0, 0, 0));
|
||
|
||
const toFinite = (value: unknown): number => {
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return 0;
|
||
return n;
|
||
};
|
||
|
||
const isWon = (opp: { status: { wonFlag: boolean } | null }) =>
|
||
Boolean(opp.status?.wonFlag);
|
||
|
||
const isLost = (opp: { status: { lostFlag: boolean } | null }) =>
|
||
Boolean(opp.status?.lostFlag);
|
||
|
||
const isClosedOpportunity = (opp: {
|
||
status: { wonFlag: boolean; lostFlag: boolean; closeFlag: boolean } | null;
|
||
closedFlag: boolean;
|
||
}) => {
|
||
if (opp.closedFlag) return true;
|
||
if (opp.status?.closeFlag) return true;
|
||
if (isWon(opp)) return true;
|
||
if (isLost(opp)) return true;
|
||
return false;
|
||
};
|
||
|
||
|
||
const computeRevenueFromControllers = (
|
||
products: Array<{
|
||
includeFlag: boolean;
|
||
taxableFlag: boolean;
|
||
cancellationType: "full" | "partial" | null;
|
||
effectiveRevenue: number;
|
||
}>,
|
||
): Omit<OpportunityRevenue, "cacheHit"> => {
|
||
let totalRevenue = 0;
|
||
let taxableRevenue = 0;
|
||
|
||
for (const item of products) {
|
||
if (!item.includeFlag) continue;
|
||
if (item.cancellationType === "full") continue;
|
||
|
||
const effectiveRevenue = Math.max(0, toFinite(item.effectiveRevenue));
|
||
totalRevenue += effectiveRevenue;
|
||
if (item.taxableFlag) taxableRevenue += effectiveRevenue;
|
||
}
|
||
|
||
const nonTaxableRevenue = totalRevenue - taxableRevenue;
|
||
|
||
return {
|
||
totalRevenue: roundCurrency(totalRevenue),
|
||
taxableRevenue: roundCurrency(taxableRevenue),
|
||
nonTaxableRevenue: roundCurrency(nonTaxableRevenue),
|
||
};
|
||
};
|
||
|
||
const readCachedOpportunityRevenue = async (
|
||
cwOpportunityId: number,
|
||
): Promise<CachedOpportunityRevenue | null> => {
|
||
const raw = await redis.get(oppRevenueKey(cwOpportunityId));
|
||
if (!raw) return null;
|
||
|
||
try {
|
||
const parsed = JSON.parse(raw) as CachedOpportunityRevenue;
|
||
return {
|
||
totalRevenue: toFinite(parsed.totalRevenue),
|
||
taxableRevenue: toFinite(parsed.taxableRevenue),
|
||
nonTaxableRevenue: toFinite(parsed.nonTaxableRevenue),
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
const writeCachedOpportunityRevenue = async (
|
||
cwOpportunityId: number,
|
||
revenue: Omit<OpportunityRevenue, "cacheHit">,
|
||
) => {
|
||
await redis.set(
|
||
oppRevenueKey(cwOpportunityId),
|
||
JSON.stringify(revenue),
|
||
"PX",
|
||
METRICS_CACHE_TTL_MS,
|
||
);
|
||
};
|
||
|
||
const resolveProbabilityRatio = (opp: { probability: number }): number =>
|
||
normalizeProbabilityRatio(opp.probability);
|
||
|
||
const getOpportunityRevenueCacheFirst = async (
|
||
cwOpportunityId: number,
|
||
opts?: RefreshSalesOpportunityMetricsCacheOptions,
|
||
): Promise<OpportunityRevenue> => {
|
||
if (!opts?.forceColdLoad) {
|
||
const cachedRevenue = await readCachedOpportunityRevenue(cwOpportunityId);
|
||
if (cachedRevenue) {
|
||
return {
|
||
...cachedRevenue,
|
||
cacheHit: true,
|
||
};
|
||
}
|
||
}
|
||
|
||
try {
|
||
const opportunity = await opportunities.fetchRecord(cwOpportunityId);
|
||
const products = await opportunity.fetchProducts({
|
||
fresh: opts?.forceColdLoad,
|
||
});
|
||
const computed = computeRevenueFromControllers(products);
|
||
await writeCachedOpportunityRevenue(cwOpportunityId, computed);
|
||
|
||
return {
|
||
...computed,
|
||
cacheHit: false,
|
||
};
|
||
} catch {
|
||
return {
|
||
totalRevenue: 0,
|
||
taxableRevenue: 0,
|
||
nonTaxableRevenue: 0,
|
||
cacheHit: false,
|
||
};
|
||
}
|
||
};
|
||
|
||
const withTimeout = async <T>(
|
||
promise: Promise<T>,
|
||
timeoutMs: number,
|
||
): Promise<T> => {
|
||
return Promise.race([
|
||
promise,
|
||
new Promise<T>((_, reject) => {
|
||
setTimeout(() => reject(new Error("Timeout")), timeoutMs);
|
||
}),
|
||
]);
|
||
};
|
||
|
||
async function mapWithConcurrency<T, R>(
|
||
items: T[],
|
||
concurrency: number,
|
||
mapper: (item: T) => Promise<R>,
|
||
): Promise<R[]> {
|
||
const results: R[] = new Array(items.length);
|
||
let index = 0;
|
||
|
||
const worker = async () => {
|
||
while (true) {
|
||
const current = index;
|
||
index += 1;
|
||
if (current >= items.length) return;
|
||
results[current] = await mapper(items[current]!);
|
||
}
|
||
};
|
||
|
||
const workers = Array.from(
|
||
{ length: Math.min(concurrency, items.length) },
|
||
() => worker(),
|
||
);
|
||
await Promise.all(workers);
|
||
return results;
|
||
}
|
||
|
||
const buildEmptyMetrics = (
|
||
memberIdentifier: string,
|
||
memberName: string,
|
||
generatedAt: string,
|
||
): MemberSalesMetrics => ({
|
||
memberIdentifier,
|
||
memberName,
|
||
generatedAt,
|
||
pipelineRevenue: 0,
|
||
closedWonRevenueMtd: 0,
|
||
closedWonRevenueYtd: 0,
|
||
winCount: { mtd: 0, ytd: 0 },
|
||
lossCount: { mtd: 0, ytd: 0 },
|
||
avgDaysToClose: 0,
|
||
openOpportunityCount: 0,
|
||
wonOpportunityCount: { mtd: 0, ytd: 0 },
|
||
lostOpportunityCount: { mtd: 0, ytd: 0 },
|
||
closedOpportunityCount: { mtd: 0, ytd: 0 },
|
||
weightedPipelineRevenue: 0,
|
||
taxablePipelineRevenue: 0,
|
||
nonTaxablePipelineRevenue: 0,
|
||
avgOpenDealSize: 0,
|
||
avgWonDealSize: { mtd: 0, ytd: 0 },
|
||
winRate: { mtd: 0, ytd: 0 },
|
||
lossRate: { mtd: 0, ytd: 0 },
|
||
assignedOpportunityCount: 0,
|
||
cacheHitCount: 0,
|
||
cacheMissCount: 0,
|
||
cacheHitRate: 0,
|
||
opportunityBreakdown: {
|
||
pipeline: [],
|
||
closedWonMtd: [],
|
||
closedWonYtd: [],
|
||
closedLostMtd: [],
|
||
closedLostYtd: [],
|
||
},
|
||
});
|
||
|
||
export async function refreshSalesOpportunityMetricsCache(
|
||
opts?: RefreshSalesOpportunityMetricsCacheOptions,
|
||
): Promise<void> {
|
||
if (salesMetricsRefreshInFlight) {
|
||
log(
|
||
"refresh requested while previous run is still in-flight; reusing existing run",
|
||
);
|
||
return salesMetricsRefreshInFlight;
|
||
}
|
||
|
||
salesMetricsRefreshInFlight = (async () => {
|
||
const startedAt = Date.now();
|
||
const forceColdLoad = opts?.forceColdLoad === true;
|
||
log(`refresh started${forceColdLoad ? " | mode=cold" : " | mode=warm"}`);
|
||
|
||
if (forceColdLoad) {
|
||
const [deletedMemberKeys, deletedRevenueKeys] = await Promise.all([
|
||
deleteKeysByPrefix(MEMBER_KEY_PREFIX),
|
||
deleteKeysByPrefix(OPP_REVENUE_KEY_PREFIX),
|
||
redis.del(ALL_MEMBERS_KEY),
|
||
]);
|
||
|
||
log(
|
||
`cold-load reset completed: memberKeysCleared=${deletedMemberKeys} oppRevenueKeysCleared=${deletedRevenueKeys}`,
|
||
);
|
||
}
|
||
|
||
const now = new Date();
|
||
const generatedAt = now.toISOString();
|
||
const monthStart = startOfMonthUtc(now);
|
||
const yearStart = startOfYearUtc(now);
|
||
|
||
try {
|
||
const activeMembers = await prisma.cwMember.findMany({
|
||
where: { inactiveFlag: false },
|
||
select: {
|
||
identifier: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
},
|
||
});
|
||
|
||
const memberIdentifiers = activeMembers.map(
|
||
(member) => member.identifier,
|
||
);
|
||
log(`members fetched: activeMembers=${memberIdentifiers.length}`);
|
||
|
||
const opportunityRows: OpportunityRow[] =
|
||
await prisma.opportunity.findMany({
|
||
where: {
|
||
AND: [
|
||
{
|
||
OR: [
|
||
{ primarySalesRepId: { in: memberIdentifiers } },
|
||
{ secondarySalesRepId: { in: memberIdentifiers } },
|
||
],
|
||
},
|
||
{ dateBecameLead: { gte: yearStart } },
|
||
{
|
||
OR: [{ closedFlag: false }, { closedDate: { gte: yearStart } }],
|
||
},
|
||
],
|
||
},
|
||
select: {
|
||
id: true,
|
||
uid: true,
|
||
name: true,
|
||
primarySalesRepId: true,
|
||
secondarySalesRepId: true,
|
||
status: {
|
||
select: {
|
||
wonFlag: true,
|
||
lostFlag: true,
|
||
closeFlag: true,
|
||
},
|
||
},
|
||
closedFlag: true,
|
||
dateBecameLead: true,
|
||
closedDate: true,
|
||
probability: true,
|
||
},
|
||
});
|
||
log(
|
||
`opportunities fetched: assignedOpportunityRows=${opportunityRows.length}`,
|
||
);
|
||
|
||
events.emit("cache:salesMetrics:refresh:started", {
|
||
activeMemberCount: memberIdentifiers.length,
|
||
opportunityCount: opportunityRows.length,
|
||
});
|
||
|
||
if (memberIdentifiers.length === 0) {
|
||
const emptyEnvelope: SalesMetricsCacheEnvelope = {
|
||
generatedAt,
|
||
activeMemberCount: 0,
|
||
memberIdentifiers: [],
|
||
members: {},
|
||
};
|
||
await redis.set(
|
||
ALL_MEMBERS_KEY,
|
||
JSON.stringify(emptyEnvelope),
|
||
"PX",
|
||
METRICS_CACHE_TTL_MS,
|
||
);
|
||
|
||
events.emit("cache:salesMetrics:refresh:completed", {
|
||
activeMemberCount: 0,
|
||
opportunityCount: 0,
|
||
memberMetricsWritten: 0,
|
||
cacheHitCount: 0,
|
||
cacheMissCount: 0,
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
log("no active members found; wrote empty cache envelope");
|
||
return;
|
||
}
|
||
|
||
const revenuePhaseStartedAt = Date.now();
|
||
let revenueLookupProcessed = 0;
|
||
let revenueLookupTimeouts = 0;
|
||
let revenueLookupFailures = 0;
|
||
let revenueLookupCacheHits = 0;
|
||
let revenueLookupCacheMisses = 0;
|
||
|
||
log(
|
||
`revenue lookup phase started: concurrency=${PRODUCT_FETCH_CONCURRENCY} timeoutMs=${PRODUCT_LOOKUP_TIMEOUT_MS}`,
|
||
);
|
||
|
||
const revenueRows = await mapWithConcurrency(
|
||
opportunityRows,
|
||
PRODUCT_FETCH_CONCURRENCY,
|
||
async (opp) => {
|
||
const [revenue, probabilityRatio] = await Promise.all([
|
||
withTimeout(
|
||
getOpportunityRevenueCacheFirst(opp.id, {
|
||
forceColdLoad,
|
||
}),
|
||
PRODUCT_LOOKUP_TIMEOUT_MS,
|
||
).catch((err: any) => {
|
||
if (err?.message === "Timeout") {
|
||
revenueLookupTimeouts += 1;
|
||
}
|
||
if (err?.message !== "Timeout") {
|
||
revenueLookupFailures += 1;
|
||
}
|
||
|
||
return {
|
||
totalRevenue: 0,
|
||
taxableRevenue: 0,
|
||
nonTaxableRevenue: 0,
|
||
cacheHit: false,
|
||
};
|
||
}),
|
||
resolveProbabilityRatio(opp),
|
||
]);
|
||
|
||
revenueLookupProcessed += 1;
|
||
if (revenue.cacheHit) revenueLookupCacheHits += 1;
|
||
if (!revenue.cacheHit) revenueLookupCacheMisses += 1;
|
||
|
||
if (revenueLookupProcessed % 100 === 0) {
|
||
log(
|
||
`revenue lookup progress: processed=${revenueLookupProcessed}/${opportunityRows.length} cacheHits=${revenueLookupCacheHits} cacheMisses=${revenueLookupCacheMisses} timeouts=${revenueLookupTimeouts} failures=${revenueLookupFailures}`,
|
||
);
|
||
}
|
||
|
||
return { oppId: opp.id, revenue, probabilityRatio };
|
||
},
|
||
);
|
||
|
||
log(
|
||
`revenue lookup phase completed in ${Date.now() - revenuePhaseStartedAt}ms: processed=${revenueLookupProcessed}/${opportunityRows.length} cacheHits=${revenueLookupCacheHits} cacheMisses=${revenueLookupCacheMisses} timeouts=${revenueLookupTimeouts} failures=${revenueLookupFailures}`,
|
||
);
|
||
|
||
const revenueByOppId = new Map(
|
||
revenueRows.map((row) => [row.oppId, row.revenue]),
|
||
);
|
||
const probabilityByOppId = new Map(
|
||
revenueRows.map((row) => [row.oppId, row.probabilityRatio]),
|
||
);
|
||
|
||
const opportunitiesByMember = new Map<string, OpportunityRow[]>();
|
||
for (const identifier of memberIdentifiers) {
|
||
opportunitiesByMember.set(identifier, []);
|
||
}
|
||
|
||
for (const opp of opportunityRows) {
|
||
const assigned = new Set<string>();
|
||
if (opp.primarySalesRepId)
|
||
assigned.add(opp.primarySalesRepId);
|
||
if (opp.secondarySalesRepId)
|
||
assigned.add(opp.secondarySalesRepId);
|
||
|
||
for (const identifier of assigned) {
|
||
const bucket = opportunitiesByMember.get(identifier);
|
||
if (!bucket) continue;
|
||
bucket.push(opp);
|
||
}
|
||
}
|
||
|
||
const members: Record<string, MemberSalesMetrics> = {};
|
||
log("member aggregation phase started");
|
||
|
||
for (const member of activeMembers) {
|
||
const identifier = member.identifier;
|
||
const assigned = opportunitiesByMember.get(identifier) ?? [];
|
||
const metric = buildEmptyMetrics(
|
||
identifier,
|
||
`${member.firstName} ${member.lastName}`.trim() || identifier,
|
||
generatedAt,
|
||
);
|
||
|
||
let wonDaysSumYtd = 0;
|
||
|
||
for (const opp of assigned) {
|
||
const revenue = revenueByOppId.get(opp.id) ?? {
|
||
totalRevenue: 0,
|
||
taxableRevenue: 0,
|
||
nonTaxableRevenue: 0,
|
||
cacheHit: false,
|
||
};
|
||
|
||
metric.cacheHitCount += revenue.cacheHit ? 1 : 0;
|
||
metric.cacheMissCount += revenue.cacheHit ? 0 : 1;
|
||
|
||
const won = isWon(opp);
|
||
const lost = isLost(opp);
|
||
const closed = isClosedOpportunity(opp);
|
||
const probabilityRatio = Math.max(
|
||
0,
|
||
Math.min(1, toFinite(probabilityByOppId.get(opp.id))),
|
||
);
|
||
|
||
const breakdownEntry: OpportunityBreakdownEntry = {
|
||
id: opp.uid,
|
||
cwId: opp.id,
|
||
name: opp.name,
|
||
revenue: revenue.totalRevenue,
|
||
taxableRevenue: revenue.taxableRevenue,
|
||
nonTaxableRevenue: revenue.nonTaxableRevenue,
|
||
probability: roundCurrency(probabilityRatio * 100),
|
||
weightedRevenue: roundCurrency(
|
||
revenue.totalRevenue * probabilityRatio,
|
||
),
|
||
closedDate: opp.closedDate?.toISOString() ?? null,
|
||
};
|
||
|
||
if (!closed) {
|
||
metric.openOpportunityCount += 1;
|
||
metric.pipelineRevenue += revenue.totalRevenue;
|
||
metric.taxablePipelineRevenue += revenue.taxableRevenue;
|
||
metric.nonTaxablePipelineRevenue += revenue.nonTaxableRevenue;
|
||
metric.weightedPipelineRevenue +=
|
||
revenue.totalRevenue * probabilityRatio;
|
||
metric.opportunityBreakdown.pipeline.push(breakdownEntry);
|
||
}
|
||
|
||
const closedDate = opp.closedDate;
|
||
if (!closedDate) continue;
|
||
|
||
const isMtd = closedDate >= monthStart;
|
||
const isYtd = closedDate >= yearStart;
|
||
|
||
if (won) {
|
||
if (isMtd) {
|
||
metric.winCount.mtd += 1;
|
||
metric.wonOpportunityCount.mtd += 1;
|
||
metric.closedOpportunityCount.mtd += 1;
|
||
metric.closedWonRevenueMtd += revenue.totalRevenue;
|
||
metric.opportunityBreakdown.closedWonMtd.push(breakdownEntry);
|
||
}
|
||
|
||
if (isYtd) {
|
||
metric.winCount.ytd += 1;
|
||
metric.wonOpportunityCount.ytd += 1;
|
||
metric.closedOpportunityCount.ytd += 1;
|
||
metric.closedWonRevenueYtd += revenue.totalRevenue;
|
||
wonDaysSumYtd += daysBetween(
|
||
opp.dateBecameLead ?? closedDate,
|
||
closedDate,
|
||
);
|
||
metric.opportunityBreakdown.closedWonYtd.push(breakdownEntry);
|
||
}
|
||
}
|
||
|
||
if (!lost) continue;
|
||
|
||
if (isMtd) {
|
||
metric.lossCount.mtd += 1;
|
||
metric.lostOpportunityCount.mtd += 1;
|
||
metric.closedOpportunityCount.mtd += 1;
|
||
metric.opportunityBreakdown.closedLostMtd.push(breakdownEntry);
|
||
}
|
||
|
||
if (!isYtd) continue;
|
||
|
||
metric.lossCount.ytd += 1;
|
||
metric.lostOpportunityCount.ytd += 1;
|
||
metric.closedOpportunityCount.ytd += 1;
|
||
metric.opportunityBreakdown.closedLostYtd.push(breakdownEntry);
|
||
}
|
||
|
||
metric.assignedOpportunityCount = assigned.length;
|
||
|
||
metric.avgDaysToClose =
|
||
metric.winCount.ytd > 0 ? wonDaysSumYtd / metric.winCount.ytd : 0;
|
||
|
||
metric.avgOpenDealSize =
|
||
metric.openOpportunityCount > 0
|
||
? metric.pipelineRevenue / metric.openOpportunityCount
|
||
: 0;
|
||
|
||
metric.avgWonDealSize.mtd =
|
||
metric.winCount.mtd > 0
|
||
? metric.closedWonRevenueMtd / metric.winCount.mtd
|
||
: 0;
|
||
|
||
metric.avgWonDealSize.ytd =
|
||
metric.winCount.ytd > 0
|
||
? metric.closedWonRevenueYtd / metric.winCount.ytd
|
||
: 0;
|
||
|
||
const closedMtd = metric.winCount.mtd + metric.lossCount.mtd;
|
||
const closedYtd = metric.winCount.ytd + metric.lossCount.ytd;
|
||
|
||
metric.winRate.mtd =
|
||
closedMtd > 0 ? metric.winCount.mtd / closedMtd : 0;
|
||
metric.winRate.ytd =
|
||
closedYtd > 0 ? metric.winCount.ytd / closedYtd : 0;
|
||
metric.lossRate.mtd =
|
||
closedMtd > 0 ? metric.lossCount.mtd / closedMtd : 0;
|
||
metric.lossRate.ytd =
|
||
closedYtd > 0 ? metric.lossCount.ytd / closedYtd : 0;
|
||
|
||
const totalLookups = metric.cacheHitCount + metric.cacheMissCount;
|
||
metric.cacheHitRate =
|
||
totalLookups > 0 ? metric.cacheHitCount / totalLookups : 0;
|
||
|
||
metric.pipelineRevenue = roundCurrency(metric.pipelineRevenue);
|
||
metric.closedWonRevenueMtd = roundCurrency(metric.closedWonRevenueMtd);
|
||
metric.closedWonRevenueYtd = roundCurrency(metric.closedWonRevenueYtd);
|
||
metric.weightedPipelineRevenue = roundCurrency(
|
||
metric.weightedPipelineRevenue,
|
||
);
|
||
metric.taxablePipelineRevenue = roundCurrency(
|
||
metric.taxablePipelineRevenue,
|
||
);
|
||
metric.nonTaxablePipelineRevenue = roundCurrency(
|
||
metric.nonTaxablePipelineRevenue,
|
||
);
|
||
metric.avgDaysToClose = roundCurrency(metric.avgDaysToClose);
|
||
metric.avgOpenDealSize = roundCurrency(metric.avgOpenDealSize);
|
||
metric.avgWonDealSize.mtd = roundCurrency(metric.avgWonDealSize.mtd);
|
||
metric.avgWonDealSize.ytd = roundCurrency(metric.avgWonDealSize.ytd);
|
||
metric.winRate.mtd = roundCurrency(metric.winRate.mtd);
|
||
metric.winRate.ytd = roundCurrency(metric.winRate.ytd);
|
||
metric.lossRate.mtd = roundCurrency(metric.lossRate.mtd);
|
||
metric.lossRate.ytd = roundCurrency(metric.lossRate.ytd);
|
||
metric.cacheHitRate = roundCurrency(metric.cacheHitRate);
|
||
|
||
members[identifier] = metric;
|
||
|
||
if (Object.keys(members).length % 25 === 0) {
|
||
log(
|
||
`member aggregation progress: aggregated=${Object.keys(members).length}/${activeMembers.length}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
log(
|
||
`member aggregation completed: totalMembers=${Object.keys(members).length}`,
|
||
);
|
||
|
||
const envelope: SalesMetricsCacheEnvelope = {
|
||
generatedAt,
|
||
activeMemberCount: memberIdentifiers.length,
|
||
memberIdentifiers,
|
||
members,
|
||
};
|
||
|
||
const pipeline = redis.pipeline();
|
||
log("redis write phase started");
|
||
pipeline.set(
|
||
ALL_MEMBERS_KEY,
|
||
JSON.stringify(envelope),
|
||
"PX",
|
||
METRICS_CACHE_TTL_MS,
|
||
);
|
||
|
||
for (const identifier of Object.keys(members)) {
|
||
pipeline.set(
|
||
memberKey(identifier),
|
||
JSON.stringify(members[identifier]),
|
||
"PX",
|
||
METRICS_CACHE_TTL_MS,
|
||
);
|
||
}
|
||
|
||
await pipeline.exec();
|
||
log("redis write phase completed");
|
||
|
||
const cacheHitCount = Object.values(members).reduce(
|
||
(sum, metric) => sum + metric.cacheHitCount,
|
||
0,
|
||
);
|
||
const cacheMissCount = Object.values(members).reduce(
|
||
(sum, metric) => sum + metric.cacheMissCount,
|
||
0,
|
||
);
|
||
|
||
events.emit("cache:salesMetrics:refresh:completed", {
|
||
activeMemberCount: memberIdentifiers.length,
|
||
opportunityCount: opportunityRows.length,
|
||
memberMetricsWritten: Object.keys(members).length,
|
||
cacheHitCount,
|
||
cacheMissCount,
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
|
||
log(
|
||
`completed in ${Date.now() - startedAt}ms | activeMembers=${memberIdentifiers.length} opportunities=${opportunityRows.length} memberMetrics=${Object.keys(members).length} cacheHits=${cacheHitCount} cacheMisses=${cacheMissCount}`,
|
||
);
|
||
} catch (error) {
|
||
log(`refresh failed in ${Date.now() - startedAt}ms: ${String(error)}`);
|
||
events.emit("cache:salesMetrics:refresh:error", {
|
||
error,
|
||
durationMs: Date.now() - startedAt,
|
||
});
|
||
throw error;
|
||
}
|
||
})().finally(() => {
|
||
salesMetricsRefreshInFlight = null;
|
||
});
|
||
|
||
return salesMetricsRefreshInFlight;
|
||
}
|
||
|
||
export async function getSalesOpportunityMetricsAll(): Promise<SalesMetricsCacheEnvelope | null> {
|
||
const raw = await redis.get(ALL_MEMBERS_KEY);
|
||
if (!raw) return null;
|
||
|
||
try {
|
||
return JSON.parse(raw) as SalesMetricsCacheEnvelope;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function getSalesOpportunityMetricsForMember(
|
||
identifier: string,
|
||
): Promise<MemberSalesMetrics | null> {
|
||
const normalized = identifier.trim().toLowerCase();
|
||
if (!normalized) return null;
|
||
|
||
const raw = await redis.get(memberKey(normalized));
|
||
if (raw) {
|
||
try {
|
||
return JSON.parse(raw) as MemberSalesMetrics;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
const all = await getSalesOpportunityMetricsAll();
|
||
if (!all) return null;
|
||
return all.members[normalized] ?? null;
|
||
}
|