// services/voiceReport/joinCallQuality.js // Join CDR legs, Media Quality rows, and Prisma WAN buckets. import { isWithinWindow } from './businessWindow.js'; import { cdrCalledNumber, cdrCallingNumber, cdrDirectionValue, cdrDispositionValue, cdrDurationSeconds, cdrField, cdrStartTime, } from '../cdrFeedParser.js'; import { groupCdrIntoCalls, summarizeCalls } from './groupCdrCalls.js'; export function phoneDigits(value) { const d = String(value || '').replace(/\D/g, ''); if (d.length === 10) return `1${d}`; if (d.length === 11 && d.startsWith('1')) return d; return d; } function cdrDisposition(item) { return cdrDispositionValue(item); } function cdrDirection(item) { return cdrDirectionValue(item); } function cdrStart(item) { return cdrStartTime(item); } function cdrDuration(item) { return cdrDurationSeconds(item); } function normalizeCdrLeg(item) { return { start: cdrStart(item), direction: cdrDirection(item), duration: cdrDuration(item), disposition: cdrDisposition(item), callingNumber: cdrCallingNumber(item), calledNumber: cdrCalledNumber(item), correlationId: cdrField(item, 'correlationId', 'Correlation ID', 'correlationID'), raw: item, }; } function mqCorrelation(row) { const n = row._norm || {}; return n.correlationid || n.finalcorrelationid || row['Correlation ID'] || null; } function mqStart(row) { const n = row._norm || {}; return n.starttime || n.callstarttime || row['Start Time'] || null; } function mqNumbers(row) { const n = row._norm || {}; return [ n.callingnumber, n.callednumber, n.remoteparty, row['Calling Number'], row['Called Number'], ].filter(Boolean).map(phoneDigits); } function mqQuality(row) { const n = row._norm || {}; const mos = Number(n.mos || n.meanopinionscore || n.audiomos || NaN); const jitter = Number(n.jitter || n.jitterms || NaN); const loss = Number(n.packetloss || n.packetlosspct || n.loss || NaN); return { mos: Number.isFinite(mos) ? mos : null, jitter: Number.isFinite(jitter) ? jitter : null, loss: Number.isFinite(loss) ? loss : null, }; } function scoreMqMatch(call, row) { const callCorr = call.correlationId ? String(call.correlationId).toLowerCase() : ''; const rowCorr = mqCorrelation(row); if (callCorr && rowCorr && String(rowCorr).toLowerCase() === callCorr) return 100; const legStart = call.start ? new Date(call.start).getTime() : NaN; const rowStart = mqStart(row) ? new Date(mqStart(row)).getTime() : NaN; if (!Number.isFinite(legStart) || !Number.isFinite(rowStart)) return 0; const delta = Math.abs(legStart - rowStart); if (delta > 120_000) return 0; let score = 50; if (delta < 15_000) score += 30; else if (delta < 60_000) score += 10; const legNums = new Set([ phoneDigits(call.callingNumber), phoneDigits(call.calledNumber), ].filter(Boolean)); const rowNums = mqNumbers(row); if (rowNums.some((n) => legNums.has(n))) score += 20; return score; } export function matchMediaQualityToCall(call, mqRows) { let best = null; let bestScore = 0; for (const row of mqRows || []) { const s = scoreMqMatch(call, row); if (s > bestScore) { bestScore = s; best = row; } } if (!best || bestScore < 50) return null; return { ...mqQuality(best), score: bestScore }; } /** @deprecated use matchMediaQualityToCall */ export function matchMediaQualityToLeg(leg, mqRows) { return matchMediaQualityToCall(leg, mqRows); } /** * Find worst Prisma 5-min bucket overlapping [start, end]. */ export function worstPrismaBucketForCall(call, appAudio, window) { if (!appAudio?.mos?.values?.length) return null; const startMs = call.start ? new Date(call.start).getTime() : NaN; const endMs = Number.isFinite(startMs) ? startMs + (call.duration || 0) * 1000 : NaN; if (!Number.isFinite(startMs)) return null; const intervalMs = 5 * 60 * 1000; const winStart = new Date(window.startTime).getTime(); const idx0 = Math.max(0, Math.floor((startMs - winStart) / intervalMs)); const idx1 = Math.max(idx0, Math.ceil((endMs - winStart) / intervalMs)); const mosVals = appAudio.mos.values; const lossVals = appAudio.loss?.values || []; const jitterVals = appAudio.jitter?.values || []; let worstMos = null; let worstLoss = null; let worstJitter = null; for (let i = idx0; i <= idx1 && i < mosVals.length; i++) { const m = mosVals[i]; if (typeof m === 'number' && (worstMos == null || m < worstMos)) worstMos = m; const l = lossVals[i]; if (typeof l === 'number' && (worstLoss == null || l > worstLoss)) worstLoss = l; const j = jitterVals[i]; if (typeof j === 'number' && (worstJitter == null || j > worstJitter)) worstJitter = j; } if (worstMos == null && worstLoss == null && worstJitter == null) return null; return { siteLevelApprox: true, mos: worstMos, loss: worstLoss, jitter: worstJitter, }; } export function summarizeDispositions(legs) { const counts = {}; for (const leg of legs) { const key = String(leg.disposition || 'unknown').toLowerCase(); counts[key] = (counts[key] || 0) + 1; } return counts; } export function joinCallQuality({ cdrItems, mqRows, appAudio, window }) { const calls = groupCdrIntoCalls(cdrItems); const joined = calls.map((call) => { const media = matchMediaQualityToCall(call, mqRows); const wan = worstPrismaBucketForCall(call, appAudio, window); return { ...call, media, wan }; }); return { calls: joined, legs: joined, // backward compat for callers expecting .legs summary: summarizeCalls(joined), }; } export function filterCdrToWindow(items, window) { return (items || []).filter((item) => { const start = cdrStartTime(item); // cdr_feed query params already scope by start time; keep rows when the // report column label differs from our camelCase guesses. if (!start) return true; return isWithinWindow(start, window); }); }