// services/callReport/groupCdrCalls.js // Group cdr_feed legs by Correlation ID into logical calls. import { cdrCalledNumber, cdrCallerIdNumber, cdrCallingLineId, cdrCallingNumber, cdrDialedDigits, cdrDirectionValue, cdrDurationSeconds, cdrField, cdrModel, cdrRecordId, cdrSiteMainNumber, cdrStartTime, cdrDedupKey, cdrUserName, cdrUserNumber, formatCallDisposition, } from '../cdrFeedParser.js'; export const CALL_BUCKETS = { inboundReachedPhone: 'inboundReachedPhone', inboundAaOnly: 'inboundAaOnly', outboundConnected: 'outboundConnected', other: 'other', }; export function cdrCorrelationId(item) { return cdrField(item, 'correlationId', 'Correlation ID', 'correlationID'); } function isTruthyAnswered(item) { const answered = String(cdrField(item, 'Answered', 'answered') || '').toLowerCase(); const indicator = String(cdrField(item, 'Answer indicator', 'answer indicator') || ''); return answered === 'true' || indicator === 'Yes'; } function isUserPhoneLeg(item) { const userType = String(cdrField(item, 'User type', 'userType') || ''); const clientType = String(cdrField(item, 'Client type', 'clientType') || '').toUpperCase(); const model = String(cdrField(item, 'Model', 'model') || ''); if (userType === 'User') return true; if (clientType === 'WXC_DEVICE') return true; if (model.toUpperCase().includes('DBS')) return true; return false; } function isAutomatedAttendantLeg(item) { const userType = String(cdrField(item, 'User type', 'userType') || '').toLowerCase(); return userType.includes('automatedattendant'); } function callTypeValue(item) { return String(cdrField(item, 'Call type', 'callType') || '').toUpperCase(); } export function classifyCallDirection(legs) { for (const leg of legs) { const ct = callTypeValue(leg); if (ct.includes('INBOUND')) return 'inbound'; if (ct.includes('OUTBOUND')) return 'outbound'; } for (const leg of legs) { const dir = cdrDirectionValue(leg); const inboundTrunk = cdrField(leg, 'Inbound trunk', 'inbound trunk'); const outboundTrunk = cdrField(leg, 'Outbound trunk', 'outbound trunk'); if (inboundTrunk && dir.includes('TERMINAT')) return 'inbound'; if (outboundTrunk && dir.includes('ORIGINAT')) return 'outbound'; } for (const leg of legs) { const dir = cdrDirectionValue(leg); if (dir.includes('ORIGINAT')) return 'outbound'; if (dir.includes('TERMINAT')) return 'inbound'; } return 'unknown'; } export function isNormalCallOutcome(item) { const outcome = String(cdrField(item, 'Call outcome', 'call outcome') || '').toLowerCase(); const reason = String(cdrField(item, 'Call outcome reason', 'call outcome reason') || '').toLowerCase(); if (!outcome) return true; if (outcome !== 'success') return false; return !reason || reason === 'normal'; } function findInboundEntryLeg(legs) { return legs.find((l) => callTypeValue(l).includes('INBOUND')) || legs[0]; } function findUserPhoneLeg(legs) { return legs.find((l) => isUserPhoneLeg(l) && isTruthyAnswered(l)) || legs.find(isUserPhoneLeg); } function findOutboundOriginatingLeg(legs) { return legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT') && isUserPhoneLeg(l)) || legs.find((l) => cdrDirectionValue(l).includes('ORIGINAT')); } function digitsOnly(value) { return String(value || '').replace(/\D/g, ''); } function pickFinalNumber(legs, mainNumber, phoneLeg) { const candidates = [ phoneLeg ? cdrCalledNumber(phoneLeg) : null, phoneLeg ? cdrUserNumber(phoneLeg) : null, phoneLeg ? cdrDialedDigits(phoneLeg) : null, ...legs.map(cdrDialedDigits), ...legs.filter(isUserPhoneLeg).map(cdrCalledNumber), ].filter(Boolean); const mainDigits = digitsOnly(mainNumber); for (const c of candidates) { const d = digitsOnly(c); if (!d) continue; if (mainDigits && d === mainDigits) continue; if (c !== mainNumber) return c; } return null; } function pickCallingLineId(legs, direction) { if (direction === 'inbound') { const entry = findInboundEntryLeg(legs); const clid = cdrCallingLineId(entry); if (clid && String(clid).toUpperCase() !== 'NA') return clid; } return null; } function pickExternalNumber(legs, direction) { for (const leg of legs) { const ct = callTypeValue(leg); if (direction === 'inbound' && ct.includes('INBOUND')) { return cdrCallingNumber(leg) || cdrCallerIdNumber(leg); } if (direction === 'outbound' && ct.includes('OUTBOUND')) { return cdrCalledNumber(leg); } } const first = legs[0]; if (!first) return null; if (direction === 'inbound') { return cdrCallingNumber(first) || cdrCallerIdNumber(first); } return cdrCalledNumber(first); } function pickStoreMainNumber(legs) { for (const leg of legs) { const main = cdrSiteMainNumber(leg); if (main) return main; } const entry = findInboundEntryLeg(legs); if (entry && classifyCallDirection(legs) === 'inbound') { return cdrCalledNumber(entry); } return null; } function pickTerminalOutcome(legs) { const phoneLegs = legs.filter(isUserPhoneLeg); const candidates = phoneLegs.length ? phoneLegs : legs; let chosen = candidates[candidates.length - 1]; for (const leg of candidates) { if (cdrField(leg, 'Call outcome', 'call outcome')) { chosen = leg; } } const outcome = cdrField(chosen, 'Call outcome', 'call outcome') || 'unknown'; const reason = cdrField(chosen, 'Call outcome reason', 'call outcome reason') || ''; return { outcome, reason, disposition: formatCallDisposition(outcome, reason), normal: isNormalCallOutcome(chosen), }; } function summarizeReach(legs, direction) { const phoneLegs = legs.filter(isUserPhoneLeg); const answeredPhone = phoneLegs.find(isTruthyAnswered); const aaLegs = legs.filter(isAutomatedAttendantLeg); const answeredAa = aaLegs.find(isTruthyAnswered); if (direction === 'inbound') { return { reachedPhone: Boolean(answeredPhone), reachedAttendant: Boolean(answeredAa), endpointUser: answeredPhone ? cdrUserName(answeredPhone) : null, endpointModel: answeredPhone ? cdrModel(answeredPhone) : null, aaKeyPress: aaLegs.map((l) => cdrField(l, 'Auto Attendant Key Pressed', 'auto attendant key pressed')) .find((v) => v && v !== 'NA') || null, }; } const originating = findOutboundOriginatingLeg(legs); const remoteAnswered = legs.some((l) => cdrDirectionValue(l).includes('TERMINAT') && isTruthyAnswered(l) && !isAutomatedAttendantLeg(l), ); return { reachedPhone: Boolean(originating && isTruthyAnswered(originating)), connected: remoteAnswered || Boolean(originating && isTruthyAnswered(originating)), endpointUser: originating ? cdrUserName(originating) : null, endpointModel: originating ? cdrModel(originating) : null, reachedAttendant: false, aaKeyPress: null, }; } /** * @param {object} call * @returns {string} */ export function assignCallBucket(call) { if (call.direction === 'inbound') { if (call.reachedPhone) return CALL_BUCKETS.inboundReachedPhone; if (call.reachedAttendant) return CALL_BUCKETS.inboundAaOnly; return CALL_BUCKETS.other; } if (call.direction === 'outbound') { if (call.connected || call.reachedPhone) return CALL_BUCKETS.outboundConnected; return CALL_BUCKETS.other; } return CALL_BUCKETS.other; } /** * @param {object[]} calls */ export function groupCallsByBucket(calls) { const buckets = { [CALL_BUCKETS.inboundReachedPhone]: [], [CALL_BUCKETS.inboundAaOnly]: [], [CALL_BUCKETS.outboundConnected]: [], [CALL_BUCKETS.other]: [], }; for (const call of calls || []) { buckets[assignCallBucket(call)].push(call); } return buckets; } /** * @param {object[]} calls */ export function filterAbnormalCalls(calls) { return (calls || []).filter((c) => c.abnormal || !c.normalOutcome); } /** * @param {object[]} rawLegs * @returns {object} */ export function summarizeCallGroup(correlationId, rawLegs) { const legs = [...(rawLegs || [])].sort((a, b) => { const ta = new Date(cdrStartTime(a) || 0).getTime(); const tb = new Date(cdrStartTime(b) || 0).getTime(); return ta - tb; }); const direction = classifyCallDirection(legs); const entryLeg = findInboundEntryLeg(legs); const phoneLeg = findUserPhoneLeg(legs); const outboundOrig = findOutboundOriginatingLeg(legs); const storeMain = pickStoreMainNumber(legs); const starts = legs.map((l) => cdrStartTime(l)).filter(Boolean); const start = starts.length ? starts.sort()[0] : null; const releaseTimes = legs.map((l) => cdrField(l, 'Release time', 'release time')).filter(Boolean); const end = releaseTimes.length ? releaseTimes.sort().reverse()[0] : null; const duration = (() => { if (start && end) { return Math.max(0, Math.round((new Date(end).getTime() - new Date(start).getTime()) / 1000)); } return Math.max(...legs.map(cdrDurationSeconds), 0); })(); const outcome = pickTerminalOutcome(legs); const reach = summarizeReach(legs, direction); const abnormal = legs.some((l) => !isNormalCallOutcome(l)); const finalNumber = pickFinalNumber(legs, storeMain, phoneLeg); const callingLineId = pickCallingLineId(legs, direction); let leftParty; let rightParty; if (direction === 'inbound') { leftParty = { lineId: callingLineId, number: pickExternalNumber(legs, direction) || cdrCallingNumber(entryLeg), }; rightParty = { main: storeMain || cdrCalledNumber(entryLeg), final: finalNumber, model: reach.endpointModel, user: reach.endpointUser, }; } else if (direction === 'outbound') { leftParty = { user: reach.endpointUser || cdrUserName(outboundOrig), number: cdrUserNumber(outboundOrig) || cdrCallingNumber(outboundOrig), model: reach.endpointModel || (outboundOrig ? cdrModel(outboundOrig) : null), }; rightParty = { number: pickExternalNumber(legs, direction) || cdrCalledNumber(legs[legs.length - 1]), }; } else { leftParty = { number: cdrCallingNumber(legs[0]) }; rightParty = { number: cdrCalledNumber(legs[legs.length - 1]) }; } return { correlationId, direction, start, end, duration, callingLineId, callingNumber: leftParty.number || pickExternalNumber(legs, direction), calledNumber: direction === 'inbound' ? (storeMain || cdrCalledNumber(entryLeg)) : (rightParty.number || cdrCalledNumber(legs[legs.length - 1])), storeMainNumber: storeMain, finalNumber, model: reach.endpointModel || (phoneLeg ? cdrModel(phoneLeg) : null), disposition: outcome.disposition, outcome: outcome.outcome, outcomeReason: outcome.reason, normalOutcome: outcome.normal && !abnormal, abnormal, legCount: legs.length, leftParty, rightParty, bucket: null, ...reach, legs, }; } /** * @param {object[]} rawItems deduped cdr_feed rows * @returns {object[]} */ export function groupCdrIntoCalls(rawItems) { const groups = new Map(); for (const item of rawItems || []) { const corr = cdrCorrelationId(item); const key = corr ? `corr:${corr}` : `solo:${cdrRecordId(item) || cdrDedupKey(item)}`; if (!groups.has(key)) groups.set(key, { correlationId: corr, legs: [] }); groups.get(key).legs.push(item); } return [...groups.values()] .map((g) => { const call = summarizeCallGroup(g.correlationId, g.legs); call.bucket = assignCallBucket(call); return call; }) .sort((a, b) => new Date(a.start || 0).getTime() - new Date(b.start || 0).getTime()); } export function summarizeCalls(calls) { let inbound = 0; let outbound = 0; let unknown = 0; let inboundReachedPhone = 0; let inboundAaOnly = 0; let outboundConnected = 0; let abnormal = 0; for (const call of calls) { if (call.direction === 'inbound') { inbound++; if (call.reachedPhone) inboundReachedPhone++; else if (call.reachedAttendant) inboundAaOnly++; } else if (call.direction === 'outbound') { outbound++; if (call.connected || call.reachedPhone) outboundConnected++; } else { unknown++; } if (call.abnormal || !call.normalOutcome) abnormal++; } const outcomes = {}; for (const call of calls) { const key = call.disposition || [call.outcome, call.outcomeReason].filter(Boolean).join(' / ') || 'unknown'; outcomes[key] = (outcomes[key] || 0) + 1; } return { total: calls.length, inbound, outbound, unknown, inboundReachedPhone, inboundAaOnly, outboundConnected, abnormal, outcomes, }; }