mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
fix: disposition for answered inbound calls + SLA timing wiring + backfill
Three related fixes:
1. Disposition for answered inbound calls
Previously the dispose endpoint sent the agent's choice to Ozonetel
but never wrote it back to the platform Call record. The webhook's
pre-disposition value ("General Enquiry" → INFO_PROVIDED) persisted.
Now: dispose endpoint finds the Call by UCID and updates disposition
to the agent's actual selection.
2. SLA timing wiring (assignedAt / answeredAt / responseTimeS)
patchCallTiming() existed but was never called. Now wired into
handleCallEvent:
- "Calling" event → writes assignedAt (ring start)
- "Answered" event → writes answeredAt + computes responseTimeS
(answeredAt - startedAt = caller wait time)
Uses patchCallTimingByUcid helper that looks up Call by UCID.
3. Backfill maint endpoint: POST /api/maint/backfill-call-disposition-timing
Walks calls for a given date, joins to CDR by UCID (both legs),
patches disposition (from CDR's mapped value, always overwrites),
timing fields (answeredAt, assignedAt, responseTimeS from CDR),
and CDR-specific durations (handlingTimeS, acwDurationS, holdDurationS).
Idempotent — safe to run multiple times.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -972,4 +972,110 @@ export class MaintController {
|
||||
this.logger.log(`[MAINT] Appointment clinic backfill complete: scanned=${appointments.length} patched=${patched} skipped=${skipped} reasons=${JSON.stringify(skippedReasons)}`);
|
||||
return { status: 'ok', scanned: appointments.length, patched, skipped, skippedReasons };
|
||||
}
|
||||
|
||||
// Backfill disposition + SLA timing on historical calls using CDR data.
|
||||
// Walks calls from a given date (IST), joins to CDR by UCID, and patches
|
||||
// disposition (from CDR's mapped value) + timing fields. Idempotent —
|
||||
// only overwrites null fields (disposition is always overwritten since
|
||||
// the webhook default is unreliable).
|
||||
@Post('backfill-call-disposition-timing')
|
||||
async backfillCallDispositionTiming(@Body() body: { date?: string }) {
|
||||
const date = body.date ?? new Date(Date.now() + 5.5 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
this.logger.log(`[MAINT] Backfill disposition+timing for date=${date}`);
|
||||
|
||||
// Fetch CDR for the date
|
||||
const cdrRows = await this.ozonetel.fetchCDR({ date }).catch(() => []);
|
||||
if (cdrRows.length === 0) return { status: 'ok', date, scanned: 0, patched: 0, skipped: 0 };
|
||||
|
||||
// Build UCID + monitorUCID map
|
||||
const byUcid = new Map<string, any>();
|
||||
for (const row of cdrRows) {
|
||||
const ucid = String(row.UCID ?? '').trim();
|
||||
const monUcid = String(row.monitorUCID ?? '').trim();
|
||||
if (ucid) byUcid.set(ucid, row);
|
||||
if (monUcid && monUcid !== ucid) byUcid.set(monUcid, row);
|
||||
}
|
||||
|
||||
// Fetch calls for the date that have a UCID
|
||||
const gte = `${date}T00:00:00+05:30`;
|
||||
const lte = `${date}T23:59:59+05:30`;
|
||||
const callsData = await this.platform.query<any>(
|
||||
`{ calls(first: 500, filter: {
|
||||
startedAt: { gte: "${gte}", lte: "${lte}" },
|
||||
ucid: { is: NOT_NULL }
|
||||
}) { edges { node {
|
||||
id ucid disposition assignedAt answeredAt responseTimeS startedAt
|
||||
} } } }`,
|
||||
).catch(() => ({ calls: { edges: [] } }));
|
||||
|
||||
const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||
let patched = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const dispositionMap: Record<string, string> = {
|
||||
'General Enquiry': 'INFO_PROVIDED',
|
||||
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
||||
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
||||
'Not Interested': 'NOT_INTERESTED',
|
||||
'Wrong Number': 'WRONG_NUMBER',
|
||||
'No Answer': 'NO_ANSWER',
|
||||
};
|
||||
|
||||
const parseHms = (hms: string | null | undefined): number | null => {
|
||||
if (!hms) return null;
|
||||
const parts = String(hms).split(':').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) return null;
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
};
|
||||
|
||||
for (const call of calls) {
|
||||
const cdrRow = byUcid.get(String(call.ucid).trim());
|
||||
if (!cdrRow) { skipped++; continue; }
|
||||
|
||||
const patch: Record<string, any> = {};
|
||||
|
||||
// Disposition — always overwrite (webhook default is unreliable)
|
||||
const cdrDisp = dispositionMap[cdrRow.Disposition] ?? null;
|
||||
if (cdrDisp) patch.disposition = cdrDisp;
|
||||
|
||||
// Timing — only fill if null
|
||||
if (!call.answeredAt && cdrRow.AnswerTime) {
|
||||
patch.answeredAt = new Date(cdrRow.AnswerTime).toISOString();
|
||||
}
|
||||
if (!call.assignedAt && cdrRow.StartTime) {
|
||||
patch.assignedAt = new Date(cdrRow.StartTime).toISOString();
|
||||
}
|
||||
if (!call.responseTimeS && call.startedAt && (patch.answeredAt || call.answeredAt)) {
|
||||
const start = new Date(call.startedAt).getTime();
|
||||
const answered = new Date(patch.answeredAt ?? call.answeredAt).getTime();
|
||||
if (!isNaN(start) && !isNaN(answered)) {
|
||||
patch.responseTimeS = Math.max(0, Math.round((answered - start) / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
// CDR timing fields
|
||||
const handlingSec = parseHms(cdrRow.HandlingTime);
|
||||
const wrapupSec = parseHms(cdrRow.WrapupDuration);
|
||||
const holdSec = parseHms(cdrRow.HoldDuration);
|
||||
if (handlingSec !== null) patch.handlingTimeS = handlingSec;
|
||||
if (wrapupSec !== null) patch.acwDurationS = wrapupSec;
|
||||
if (holdSec !== null) patch.holdDurationS = holdSec;
|
||||
|
||||
if (Object.keys(patch).length === 0) { skipped++; continue; }
|
||||
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: call.id, data: patch },
|
||||
);
|
||||
patched++;
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[MAINT] Backfill patch failed for ${call.id}: ${err.message}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`[MAINT] Disposition+timing backfill complete: date=${date} scanned=${calls.length} patched=${patched} skipped=${skipped}`);
|
||||
return { status: 'ok', date, scanned: calls.length, patched, skipped };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user