fix: remove nested .git folders, re-add as normal directories
This commit is contained in:
+659
@@ -0,0 +1,659 @@
|
||||
/**
|
||||
* @module opportunityCache
|
||||
*
|
||||
* Redis-backed cache for expensive ConnectWise API data associated
|
||||
* with opportunities.
|
||||
*
|
||||
* ## What is cached
|
||||
*
|
||||
* Each non-closed opportunity may have cached payloads keyed by its `cwOpportunityId`:
|
||||
*
|
||||
* - **Activities** (`opp:activities:{cwOpportunityId}`) — the raw `CWActivity[]` array
|
||||
* - **Company CW data** (`opp:company-cw:{cw_CompanyId}`) — hydrated company / contacts blob
|
||||
* - **Notes** (`opp:notes:{cwOpportunityId}`) — raw CW notes array
|
||||
* - **Contacts** (`opp:contacts:{cwOpportunityId}`) — raw CW contacts array
|
||||
* - **Products** (`opp:products:{cwOpportunityId}`) — raw CW forecast + procurement products blob
|
||||
*
|
||||
* TTLs are computed dynamically via {@link computeCacheTTL}.
|
||||
*
|
||||
* ## Background refresh (Worker-based)
|
||||
*
|
||||
* **⚠️ This module is now READ-ONLY.** Cache refresh logic has been moved to workers:
|
||||
*
|
||||
* - {@link refreshActiveOpportunitiesWorker} — Scheduled to run every 20 minutes
|
||||
* to run a unified cache pass across all opportunities. Active/recent records
|
||||
* use adaptive TTLs and archived records use {@link TTL_ARCHIVED_MS}.
|
||||
*
|
||||
* See `src/modules/workers/cache/` for worker implementations.
|
||||
*
|
||||
* ## This module now provides
|
||||
*
|
||||
* - `getCached*()` functions for reading cached data
|
||||
* - `fetchAndCache*()` functions used internally by workers
|
||||
* - `invalidate*()` functions for cache invalidation after mutations
|
||||
* - Cache key helpers for Redis operations
|
||||
*/
|
||||
|
||||
import { prisma, redis } from "../../constants";
|
||||
import { activityCw } from "../cw-utils/activities/activities";
|
||||
import { computeCacheTTL } from "../algorithms/computeCacheTTL";
|
||||
import { computeSubResourceCacheTTL } from "../algorithms/computeSubResourceCacheTTL";
|
||||
import {
|
||||
computeProductsCacheTTL,
|
||||
PRODUCTS_TTL_HOT,
|
||||
} from "../algorithms/computeProductsCacheTTL";
|
||||
import { connectWiseApi } from "../../constants";
|
||||
import { fetchCwCompanyById } from "../cw-utils/fetchCompany";
|
||||
import { fetchCompanySite } from "../cw-utils/sites/companySites";
|
||||
import { opportunityCw } from "../cw-utils/opportunities/opportunities";
|
||||
import { withCwRetry } from "../cw-utils/withCwRetry";
|
||||
import { events } from "../globalEvents";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ACTIVITY_PREFIX = "opp:activities:";
|
||||
const COMPANY_CW_PREFIX = "opp:company-cw:";
|
||||
const NOTES_PREFIX = "opp:notes:";
|
||||
const CONTACTS_PREFIX = "opp:contacts:";
|
||||
const PRODUCTS_PREFIX = "opp:products:";
|
||||
const SITE_PREFIX = "opp:site:";
|
||||
const OPP_CW_PREFIX = "opp:cw-data:";
|
||||
|
||||
/** Redis key for cached activities by CW opportunity ID. */
|
||||
export const activityCacheKey = (cwOppId: number) =>
|
||||
`${ACTIVITY_PREFIX}${cwOppId}`;
|
||||
|
||||
/** Redis key for cached company CW hydration data by CW company ID. */
|
||||
export const companyCwCacheKey = (cwCompanyId: number) =>
|
||||
`${COMPANY_CW_PREFIX}${cwCompanyId}`;
|
||||
|
||||
/** Redis key for cached opportunity notes by CW opportunity ID. */
|
||||
export const notesCacheKey = (cwOppId: number) => `${NOTES_PREFIX}${cwOppId}`;
|
||||
|
||||
/** Redis key for cached opportunity contacts by CW opportunity ID. */
|
||||
export const contactsCacheKey = (cwOppId: number) =>
|
||||
`${CONTACTS_PREFIX}${cwOppId}`;
|
||||
|
||||
/** Redis key for cached opportunity products by CW opportunity ID. */
|
||||
export const productsCacheKey = (cwOppId: number) =>
|
||||
`${PRODUCTS_PREFIX}${cwOppId}`;
|
||||
|
||||
/** Redis key for cached company site by CW company ID + site ID. */
|
||||
export const siteCacheKey = (cwCompanyId: number, cwSiteId: number) =>
|
||||
`${SITE_PREFIX}${cwCompanyId}:${cwSiteId}`;
|
||||
|
||||
/** Redis key for cached CW opportunity response by CW opportunity ID. */
|
||||
export const oppCwDataCacheKey = (cwOppId: number) =>
|
||||
`${OPP_CW_PREFIX}${cwOppId}`;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Retrieve cached CW activities for an opportunity.
|
||||
*
|
||||
* @returns The parsed `CWActivity[]` or `null` on cache miss.
|
||||
*/
|
||||
export async function getCachedActivities(
|
||||
cwOpportunityId: number,
|
||||
): Promise<any[] | null> {
|
||||
const raw = await redis.get(activityCacheKey(cwOpportunityId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve cached company CW hydration data.
|
||||
*
|
||||
* @returns `{ company, defaultContact, allContacts }` or `null` on cache miss.
|
||||
*/
|
||||
export async function getCachedCompanyCwData(
|
||||
cwCompanyId: number,
|
||||
): Promise<{ company: any; defaultContact: any; allContacts: any[] } | null> {
|
||||
const raw = await redis.get(companyCwCacheKey(cwCompanyId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve cached opportunity notes (raw CW data).
|
||||
*
|
||||
* @returns The parsed raw CW notes array or `null` on cache miss.
|
||||
*/
|
||||
export async function getCachedNotes(
|
||||
cwOpportunityId: number,
|
||||
): Promise<any[] | null> {
|
||||
const raw = await redis.get(notesCacheKey(cwOpportunityId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve cached opportunity contacts (raw CW data).
|
||||
*
|
||||
* @returns The parsed raw CW contacts array or `null` on cache miss.
|
||||
*/
|
||||
export async function getCachedContacts(
|
||||
cwOpportunityId: number,
|
||||
): Promise<any[] | null> {
|
||||
const raw = await redis.get(contactsCacheKey(cwOpportunityId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve cached opportunity products (raw CW forecast + procurement blob).
|
||||
*
|
||||
* @returns `{ forecast, procProducts }` or `null` on cache miss.
|
||||
*/
|
||||
export async function getCachedProducts(
|
||||
cwOpportunityId: number,
|
||||
): Promise<{ forecast: any; procProducts: any[] } | null> {
|
||||
const raw = await redis.get(productsCacheKey(cwOpportunityId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve cached CW site data for a company/site pair.
|
||||
*
|
||||
* @returns Parsed site data or `null` on cache miss.
|
||||
*/
|
||||
export async function getCachedSite(
|
||||
cwCompanyId: number,
|
||||
cwSiteId: number,
|
||||
): Promise<any | null> {
|
||||
const raw = await redis.get(siteCacheKey(cwCompanyId, cwSiteId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve cached CW opportunity response data.
|
||||
*
|
||||
* @returns Parsed CW opportunity object or `null` on cache miss.
|
||||
*/
|
||||
export async function getCachedOppCwData(
|
||||
cwOpportunityId: number,
|
||||
): Promise<any | null> {
|
||||
const raw = await redis.get(oppCwDataCacheKey(cwOpportunityId));
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check whether an error is an Axios 404 (resource not found in CW). */
|
||||
function isNotFoundError(err: unknown): boolean {
|
||||
if (typeof err !== "object" || err === null) return false;
|
||||
const e = err as Record<string, any>;
|
||||
return e.isAxiosError === true && e.response?.status === 404;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an error is a transient network / timeout error.
|
||||
*
|
||||
* These are safe to swallow in background refresh tasks — CW will be
|
||||
* retried on the next refresh cycle. Logs a concise one-line warning
|
||||
* instead of dumping the full Axios error object.
|
||||
*/
|
||||
function isTransientError(err: unknown): boolean {
|
||||
if (typeof err !== "object" || err === null) return false;
|
||||
const e = err as Record<string, any>;
|
||||
if (!e.isAxiosError) return false;
|
||||
const code = e.code as string | undefined;
|
||||
return (
|
||||
code === "ECONNABORTED" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
code === "ECONNRESET" ||
|
||||
code === "ETIMEDOUT" ||
|
||||
code === "ERR_NETWORK" ||
|
||||
code === "ENETUNREACH" ||
|
||||
code === "ERR_BAD_RESPONSE"
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a concise error description for logging (avoids dumping entire Axios objects). */
|
||||
function describeError(err: unknown): string {
|
||||
if (typeof err !== "object" || err === null) return String(err);
|
||||
const e = err as Record<string, any>;
|
||||
if (e.isAxiosError) {
|
||||
const method = (e.config?.method ?? "?").toUpperCase();
|
||||
const url = e.config?.url ?? "unknown";
|
||||
const code = e.code ?? "";
|
||||
const status = e.response?.status ?? "";
|
||||
return `${method} ${url} → ${code || `HTTP ${status}`} (${e.message})`;
|
||||
}
|
||||
return e.message ?? String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* When true, transient-error warnings inside fetchAndCache* are suppressed.
|
||||
* Used during background refresh to avoid flooding the terminal — the
|
||||
* refresh function prints a single summary line instead.
|
||||
*/
|
||||
let _suppressTransientWarnings = false;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Write helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch activities from CW and cache them with the appropriate TTL.
|
||||
*
|
||||
* Returns an empty array if CW responds with 404 (opportunity doesn't
|
||||
* exist or was deleted upstream).
|
||||
*
|
||||
* @returns The raw `CWActivity[]` collection (as plain array).
|
||||
*/
|
||||
export async function fetchAndCacheActivities(
|
||||
cwOpportunityId: number,
|
||||
ttlMs: number,
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
// Use the direct (single-call) variant to avoid the extra count request
|
||||
const arr = await activityCw.fetchByOpportunityDirect(cwOpportunityId);
|
||||
await redis.set(
|
||||
activityCacheKey(cwOpportunityId),
|
||||
JSON.stringify(arr),
|
||||
"PX",
|
||||
ttlMs,
|
||||
);
|
||||
return arr;
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return [];
|
||||
if (isTransientError(err)) {
|
||||
console.warn(
|
||||
`[cache] activities opp#${cwOpportunityId}: ${describeError(err)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch company CW data (company, contacts) and cache with the given TTL.
|
||||
*
|
||||
* @returns The hydration blob or `null` if the company doesn't exist in CW.
|
||||
*/
|
||||
export async function fetchAndCacheCompanyCwData(
|
||||
cwCompanyId: number,
|
||||
ttlMs: number,
|
||||
): Promise<{ company: any; defaultContact: any; allContacts: any[] } | null> {
|
||||
try {
|
||||
// Fetch company and all-contacts in parallel — the allContacts URL
|
||||
// can be constructed directly without the company response.
|
||||
const [cwCompany, allContactsData] = await Promise.all([
|
||||
fetchCwCompanyById(cwCompanyId),
|
||||
withCwRetry(
|
||||
() =>
|
||||
connectWiseApi.get(
|
||||
`/company/companies/${cwCompanyId}/contacts?pageSize=1000`,
|
||||
),
|
||||
{ label: `company#${cwCompanyId}/allContacts` },
|
||||
),
|
||||
]);
|
||||
|
||||
if (!cwCompany) return null;
|
||||
|
||||
// Default contact: derive from allContacts instead of making an
|
||||
// extra serial CW call. The company object carries the default
|
||||
// contact's ID, so we can pull it from the list we already fetched.
|
||||
const defaultContactId = cwCompany.defaultContact?.id;
|
||||
const defaultContactData = defaultContactId
|
||||
? ((allContactsData.data as any[]).find(
|
||||
(c: any) => c.id === defaultContactId,
|
||||
) ?? null)
|
||||
: null;
|
||||
|
||||
const blob = {
|
||||
company: cwCompany,
|
||||
defaultContact: defaultContactData,
|
||||
allContacts: allContactsData.data,
|
||||
};
|
||||
|
||||
await redis.set(
|
||||
companyCwCacheKey(cwCompanyId),
|
||||
JSON.stringify(blob),
|
||||
"PX",
|
||||
ttlMs,
|
||||
);
|
||||
|
||||
return blob;
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return null;
|
||||
if (isTransientError(err)) {
|
||||
console.warn(`[cache] company#${cwCompanyId}: ${describeError(err)}`);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch opportunity notes from CW and cache the raw response.
|
||||
*
|
||||
* Returns an empty array if CW responds with 404.
|
||||
*
|
||||
* @returns The raw CW notes array.
|
||||
*/
|
||||
export async function fetchAndCacheNotes(
|
||||
cwOpportunityId: number,
|
||||
ttlMs: number,
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
const notes = await opportunityCw.fetchNotes(cwOpportunityId);
|
||||
await redis.set(
|
||||
notesCacheKey(cwOpportunityId),
|
||||
JSON.stringify(notes),
|
||||
"PX",
|
||||
ttlMs,
|
||||
);
|
||||
return notes;
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return [];
|
||||
if (isTransientError(err)) {
|
||||
console.warn(
|
||||
`[cache] notes opp#${cwOpportunityId}: ${describeError(err)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch opportunity contacts from CW and cache the raw response.
|
||||
*
|
||||
* Returns an empty array if CW responds with 404.
|
||||
*
|
||||
* @returns The raw CW contacts array.
|
||||
*/
|
||||
export async function fetchAndCacheContacts(
|
||||
cwOpportunityId: number,
|
||||
ttlMs: number,
|
||||
): Promise<any[]> {
|
||||
try {
|
||||
const contacts = await opportunityCw.fetchContacts(cwOpportunityId);
|
||||
await redis.set(
|
||||
contactsCacheKey(cwOpportunityId),
|
||||
JSON.stringify(contacts),
|
||||
"PX",
|
||||
ttlMs,
|
||||
);
|
||||
return contacts;
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return [];
|
||||
if (isTransientError(err)) {
|
||||
console.warn(
|
||||
`[cache] contacts opp#${cwOpportunityId}: ${describeError(err)}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached notes for an opportunity.
|
||||
*
|
||||
* Call this after any note mutation (create, update, delete) so the
|
||||
* next read refreshes from ConnectWise.
|
||||
*/
|
||||
export async function invalidateNotesCache(
|
||||
cwOpportunityId: number,
|
||||
): Promise<void> {
|
||||
await redis.del(notesCacheKey(cwOpportunityId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached contacts for an opportunity.
|
||||
*
|
||||
* Call this after any contact mutation so the next read refreshes
|
||||
* from ConnectWise.
|
||||
*/
|
||||
export async function invalidateContactsCache(
|
||||
cwOpportunityId: number,
|
||||
): Promise<void> {
|
||||
await redis.del(contactsCacheKey(cwOpportunityId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch opportunity products (forecast + procurement) from CW and cache.
|
||||
*
|
||||
* Stores both the forecast response and procurement products together
|
||||
* so that `fetchProducts()` can reconstruct ForecastProductControllers
|
||||
* from a single cache hit.
|
||||
*
|
||||
* @returns `{ forecast, procProducts }` blob.
|
||||
*/
|
||||
export async function fetchAndCacheProducts(
|
||||
cwOpportunityId: number,
|
||||
ttlMs: number,
|
||||
): Promise<{ forecast: any; procProducts: any[] }> {
|
||||
try {
|
||||
const [forecast, procProducts] = await Promise.all([
|
||||
opportunityCw.fetchProducts(cwOpportunityId),
|
||||
opportunityCw.fetchProcurementProducts(cwOpportunityId),
|
||||
]);
|
||||
|
||||
const blob = { forecast, procProducts };
|
||||
await redis.set(
|
||||
productsCacheKey(cwOpportunityId),
|
||||
JSON.stringify(blob),
|
||||
"PX",
|
||||
ttlMs,
|
||||
);
|
||||
return blob;
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err))
|
||||
return { forecast: { forecastItems: [] }, procProducts: [] };
|
||||
if (isTransientError(err)) {
|
||||
console.warn(
|
||||
`[cache] products opp#${cwOpportunityId}: ${describeError(err)}`,
|
||||
);
|
||||
return { forecast: { forecastItems: [] }, procProducts: [] };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached products for an opportunity.
|
||||
*
|
||||
* Call this after any product mutation (add, update, resequence) so the
|
||||
* next read refreshes from ConnectWise.
|
||||
*/
|
||||
export async function invalidateProductsCache(
|
||||
cwOpportunityId: number,
|
||||
): Promise<void> {
|
||||
await redis.del(productsCacheKey(cwOpportunityId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cached data for an opportunity.
|
||||
*
|
||||
* Removes activities, notes, contacts, products, and CW data cache keys.
|
||||
* Call this when an opportunity is deleted.
|
||||
*/
|
||||
export async function invalidateAllOpportunityCaches(
|
||||
cwOpportunityId: number,
|
||||
): Promise<void> {
|
||||
await redis.del(
|
||||
activityCacheKey(cwOpportunityId),
|
||||
notesCacheKey(cwOpportunityId),
|
||||
contactsCacheKey(cwOpportunityId),
|
||||
productsCacheKey(cwOpportunityId),
|
||||
oppCwDataCacheKey(cwOpportunityId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Site TTL — 20 minutes. Site/address data rarely changes so we cache
|
||||
* aggressively. The background refresh does NOT proactively warm site keys;
|
||||
* they are populated lazily on the first detail-view request.
|
||||
*/
|
||||
const SITE_TTL_MS = 1_200_000;
|
||||
|
||||
/**
|
||||
* Fetch a CW company site from ConnectWise and cache the result.
|
||||
*
|
||||
* @returns The raw CW site object.
|
||||
*/
|
||||
export async function fetchAndCacheSite(
|
||||
cwCompanyId: number,
|
||||
cwSiteId: number,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const site = await fetchCompanySite(cwCompanyId, cwSiteId);
|
||||
await redis.set(
|
||||
siteCacheKey(cwCompanyId, cwSiteId),
|
||||
JSON.stringify(site),
|
||||
"PX",
|
||||
SITE_TTL_MS,
|
||||
);
|
||||
return site;
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return null;
|
||||
if (isTransientError(err)) {
|
||||
console.warn(
|
||||
`[cache] site company#${cwCompanyId}/site#${cwSiteId}: ${describeError(err)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the raw CW opportunity response from ConnectWise and cache it.
|
||||
*
|
||||
* Used by `fetchItem()` in the manager to avoid a CW roundtrip when
|
||||
* the detail view is reloaded within the cache TTL window.
|
||||
*
|
||||
* @param cwOpportunityId - The CW opportunity ID
|
||||
* @param ttlMs - Cache TTL in milliseconds
|
||||
* @returns The raw CW opportunity response object.
|
||||
*/
|
||||
export async function fetchAndCacheOppCwData(
|
||||
cwOpportunityId: number,
|
||||
ttlMs: number,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const cwData = await opportunityCw.fetch(cwOpportunityId);
|
||||
await redis.set(
|
||||
oppCwDataCacheKey(cwOpportunityId),
|
||||
JSON.stringify(cwData),
|
||||
"PX",
|
||||
ttlMs,
|
||||
);
|
||||
return cwData;
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return null;
|
||||
if (isTransientError(err)) {
|
||||
console.warn(`[cache] opp#${cwOpportunityId}: ${describeError(err)}`);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background refresh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fixed 24-hour TTL used for archived (closed > 30 days) opportunity cache entries.
|
||||
* These opportunities are outside the adaptive-TTL window and are rebuilt once per
|
||||
* day at midnight via {@link refreshArchivedOpportunityCache}.
|
||||
*/
|
||||
export const TTL_ARCHIVED_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
/**
|
||||
* Cache opportunities that fall outside the adaptive-TTL window — i.e. those
|
||||
* closed **more than 30 days ago** — with a fixed 24-hour TTL.
|
||||
*
|
||||
* These opportunities are excluded by {@link computeCacheTTL} (returns `null`)
|
||||
* and are therefore never warmed by {@link refreshOpportunityCache}. This
|
||||
* function fills that gap so archived deals are still served from cache on
|
||||
* the rare occasion they are accessed.
|
||||
*
|
||||
* ## Scheduling
|
||||
*
|
||||
* Designed to be triggered once per day at midnight from `src/index.ts`. At
|
||||
* midnight `force` is `true` so every key is unconditionally overwritten,
|
||||
* ensuring data is no more than 24 hours stale.
|
||||
*
|
||||
* On startup `force` defaults to `false` so only truly missing keys are
|
||||
* populated; this avoids a large CW burst on every process restart.
|
||||
*
|
||||
* @param force - When `true`, overwrite every cache key without checking
|
||||
* whether it already exists. Defaults to `false`.
|
||||
*/
|
||||
/**
|
||||
* TODO: This function has been moved to a worker at
|
||||
* `src/modules/workers/cache/refreshArchivedOpportunities.ts`
|
||||
*
|
||||
* Wire up the worker to run daily at midnight with force=true to ensure
|
||||
* archived opportunities (closed > 30 days) have fresh cache entries.
|
||||
*
|
||||
* @deprecated - Use refreshArchivedOpportunitiesWorker from the worker module
|
||||
*/
|
||||
export async function refreshArchivedOpportunityCache(
|
||||
force = false,
|
||||
): Promise<void> {
|
||||
throw new Error(
|
||||
"refreshArchivedOpportunityCache has been moved to a worker. " +
|
||||
"Use refreshArchivedOpportunitiesWorker from src/modules/workers/cache/refreshArchivedOpportunities.ts",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: This function has been moved to a worker at
|
||||
* `src/modules/workers/cache/refreshActiveOpportunities.ts`
|
||||
*
|
||||
* Wire up the worker to run every 30 seconds to refresh cache for active
|
||||
* and recently-closed (within 30 days) opportunities.
|
||||
*
|
||||
* @deprecated - Use refreshActiveOpportunitiesWorker from the worker module
|
||||
*/
|
||||
export async function refreshOpportunityCache(): Promise<void> {
|
||||
throw new Error(
|
||||
"refreshOpportunityCache has been moved to a worker. " +
|
||||
"Use refreshActiveOpportunitiesWorker from src/modules/workers/cache/refreshActiveOpportunities.ts",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,900 @@
|
||||
import { prisma, redis } from "../../constants";
|
||||
import { getCachedOppCwData, getCachedProducts } from "./opportunityCache";
|
||||
import { OpportunityStatus } from "../../workflows/wf.opportunity";
|
||||
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: string;
|
||||
cwOpportunityId: number;
|
||||
name: string;
|
||||
primarySalesRepIdentifier: string | null;
|
||||
secondarySalesRepIdentifier: string | null;
|
||||
statusCwId: number | null;
|
||||
statusName: string | 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: {
|
||||
statusCwId: number | null;
|
||||
statusName: string | null;
|
||||
closedFlag: boolean;
|
||||
}) => {
|
||||
if (opp.statusCwId === OpportunityStatus.Won) return true;
|
||||
if (opp.statusName?.toLowerCase().includes("won")) return true;
|
||||
if (opp.closedFlag && opp.statusName?.toLowerCase().includes("won"))
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const isLost = (opp: {
|
||||
statusCwId: number | null;
|
||||
statusName: string | null;
|
||||
closedFlag: boolean;
|
||||
}) => {
|
||||
if (opp.statusCwId === OpportunityStatus.Lost) return true;
|
||||
if (opp.statusName?.toLowerCase().includes("lost")) return true;
|
||||
if (opp.closedFlag && opp.statusName?.toLowerCase().includes("lost"))
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const isClosedOpportunity = (opp: {
|
||||
statusCwId: number | null;
|
||||
statusName: string | null;
|
||||
closedFlag: boolean;
|
||||
}) => {
|
||||
if (opp.closedFlag) return true;
|
||||
if (isWon(opp)) return true;
|
||||
if (isLost(opp)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const buildCancellationMap = (procProducts: any[]) => {
|
||||
const map = new Map<number, any>();
|
||||
|
||||
for (const pp of procProducts) {
|
||||
const rawForecastDetailId = pp?.forecastDetailId;
|
||||
const forecastDetailId =
|
||||
typeof rawForecastDetailId === "number"
|
||||
? rawForecastDetailId
|
||||
: Number(rawForecastDetailId);
|
||||
|
||||
if (Number.isFinite(forecastDetailId) && forecastDetailId > 0) {
|
||||
map.set(forecastDetailId, pp);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
};
|
||||
|
||||
const computeRevenueFromProductsBlob = (
|
||||
blob: any,
|
||||
): Omit<OpportunityRevenue, "cacheHit"> => {
|
||||
const forecastItems = Array.isArray(blob?.forecast?.forecastItems)
|
||||
? blob.forecast.forecastItems
|
||||
: [];
|
||||
const procProducts = Array.isArray(blob?.procProducts)
|
||||
? blob.procProducts
|
||||
: [];
|
||||
|
||||
const cancellationMap = buildCancellationMap(procProducts);
|
||||
|
||||
let totalRevenue = 0;
|
||||
let taxableRevenue = 0;
|
||||
|
||||
for (const item of forecastItems) {
|
||||
if (!cancellationMap.has(item?.id)) continue;
|
||||
if (!item?.includeFlag) continue;
|
||||
|
||||
const quantity = Math.max(0, toFinite(item?.quantity));
|
||||
const revenue = toFinite(item?.revenue);
|
||||
|
||||
const cancellation = cancellationMap.get(item.id);
|
||||
const cancelledFlag = Boolean(cancellation?.cancelledFlag);
|
||||
const quantityCancelled = Math.max(
|
||||
0,
|
||||
toFinite(cancellation?.quantityCancelled),
|
||||
);
|
||||
|
||||
if (cancelledFlag && quantity > 0 && quantityCancelled >= quantity)
|
||||
continue;
|
||||
|
||||
const ratio =
|
||||
quantity > 0 ? Math.max(0, (quantity - quantityCancelled) / quantity) : 1;
|
||||
const effectiveRevenue = revenue * ratio;
|
||||
|
||||
totalRevenue += effectiveRevenue;
|
||||
if (item?.taxableFlag) taxableRevenue += effectiveRevenue;
|
||||
}
|
||||
|
||||
const nonTaxableRevenue = totalRevenue - taxableRevenue;
|
||||
|
||||
return {
|
||||
totalRevenue: roundCurrency(totalRevenue),
|
||||
taxableRevenue: roundCurrency(taxableRevenue),
|
||||
nonTaxableRevenue: roundCurrency(nonTaxableRevenue),
|
||||
};
|
||||
};
|
||||
|
||||
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 = async (opp: {
|
||||
cwOpportunityId: number;
|
||||
probability: number;
|
||||
}): Promise<number> => {
|
||||
const fromDb = normalizeProbabilityRatio(opp.probability);
|
||||
if (fromDb > 0) return fromDb;
|
||||
|
||||
const cachedCwOpp = await getCachedOppCwData(opp.cwOpportunityId);
|
||||
if (!cachedCwOpp) return 0;
|
||||
|
||||
const rawProbability =
|
||||
cachedCwOpp?.probability?.name ?? cachedCwOpp?.probability ?? 0;
|
||||
return normalizeProbabilityRatio(rawProbability);
|
||||
};
|
||||
|
||||
const getOpportunityRevenueCacheFirst = async (
|
||||
cwOpportunityId: number,
|
||||
opts?: RefreshSalesOpportunityMetricsCacheOptions,
|
||||
): Promise<OpportunityRevenue> => {
|
||||
if (!opts?.forceColdLoad) {
|
||||
const cachedRevenue = await readCachedOpportunityRevenue(cwOpportunityId);
|
||||
if (cachedRevenue) {
|
||||
return {
|
||||
...cachedRevenue,
|
||||
cacheHit: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!opts?.forceColdLoad) {
|
||||
const cachedProducts = await getCachedProducts(cwOpportunityId);
|
||||
if (cachedProducts) {
|
||||
const computed = computeRevenueFromProductsBlob(cachedProducts);
|
||||
await writeCachedOpportunityRevenue(cwOpportunityId, computed);
|
||||
return {
|
||||
...computed,
|
||||
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: [
|
||||
{ primarySalesRepIdentifier: { in: memberIdentifiers } },
|
||||
{ secondarySalesRepIdentifier: { in: memberIdentifiers } },
|
||||
],
|
||||
},
|
||||
{ dateBecameLead: { gte: yearStart } },
|
||||
{
|
||||
OR: [{ closedFlag: false }, { closedDate: { gte: yearStart } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
cwOpportunityId: true,
|
||||
name: true,
|
||||
primarySalesRepIdentifier: true,
|
||||
secondarySalesRepIdentifier: true,
|
||||
statusCwId: true,
|
||||
statusName: 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.cwOpportunityId, {
|
||||
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.primarySalesRepIdentifier)
|
||||
assigned.add(opp.primarySalesRepIdentifier);
|
||||
if (opp.secondarySalesRepIdentifier)
|
||||
assigned.add(opp.secondarySalesRepIdentifier);
|
||||
|
||||
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.id,
|
||||
cwId: opp.cwOpportunityId,
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user