'reporting.browse', 'create' => 'reporting.create', 'update' => 'reporting.update', 'destroy' => 'reporting.destroy', ]; protected $storeRequest = \Illuminate\Http\Request::class; protected $updateRequest = \Illuminate\Http\Request::class; protected $moduleName = 'reporting'; protected $SettingsHelper; public function __construct() { parent::__construct(); $this->SettingsHelper = new SettingsHelper(); } /** * GET /api/reporting/missed * * Returns paginated missed (abandoned / exit-with-timeout) queue calls. * * Filters (query params): * - daterange : "YYYY-MM-DD HH:mm:ss - YYYY-MM-DD HH:mm:ss" * - queuename : Queue extension / group ID * - search : Free-text search (queue description or caller number) * - per_page : Items per page (default 10) * - page : Page number */ public function missed(Request $request) { try { $user = Auth::user(); // Company groups visible to this user $company_groups = $user->accessibleQueueIds(); $conds = $this->SettingsHelper->defaultConds(); if ($request->has('daterange')) { try { $range = explode(' - ', $request->daterange); $conds['from'] = Carbon::createFromFormat('Y-m-d H:i:s', trim($range[0]))->format('Y-m-d H:i:s'); $conds['to'] = Carbon::createFromFormat('Y-m-d H:i:s', trim($range[1]))->format('Y-m-d H:i:s'); } catch (\Exception $e) { $conds['from'] = Carbon::now()->startOfDay()->format('Y-m-d H:i:s'); $conds['to'] = Carbon::now()->endOfDay()->format('Y-m-d H:i:s'); } } // Base query — same source & logic as MissedController::missed(): // inbound_reports (call_time in range, call_status = missed, visible queues) $query = InboundReport::whereBetween('call_time', [$conds['from'], $conds['to']]) ->where('call_status', 'missed') ->whereIn('enter_queuename', $company_groups); // --- Role-based access --- if ($user->access_to_all_entity != '1' && $user->groups->isNotEmpty()) { $query->whereIn('enter_queuename', $user->accessibleQueueIds()); } // --- Filters --- if ($request->filled('queuename')) { $query->where('enter_queuename', $request->queuename); } if ($request->filled('search')) { $searchValue = strtolower(trim($request->search)); $query->where(function ($q) use ($searchValue) { $q->orWhere(DB::raw('LOWER(queue_description)'), 'LIKE', "%{$searchValue}%") ->orWhere(DB::raw('LOWER(caller_number)'), 'LIKE', "%{$searchValue}%") ->orWhere(DB::raw('LOWER(did)'), 'LIKE', "%{$searchValue}%"); }); } $perPage = (int) $request->get('per_page', 10); $result = $query->orderBy('call_time', 'desc')->paginate($perPage); // Map inbound_reports columns onto the same fields/keys the API already exposed $result->getCollection()->transform(function ($item) { $row = (object) [ 'callid' => $item->callid, 'call_time' => Carbon::parse($item->call_time)->format('Y-m-d h:i:s A'), 'queuename' => $item->enter_queuename, 'queue_name' => $item->queue_description, 'hangup_type' => $item->hangup_type, 'hangup_reason' => $item->status, 'ring_duration' => gmdate('H:i:s', (int) ($item->ringtime ?? 0)), 'caller_number' => $item->caller_number, ]; $row->ring_details = DB::table('asteriskcdrdb.queuelog') ->select('agent', 'event', 'data1', 'time') ->where('callid', $item->callid) ->whereIn('event', ['RINGNOANSWER', 'RINGCANCELED']) ->where('data1', '>', 1000) ->orderBy('time', 'asc') ->get() ->map(function ($r) { return [ 'agent' => $r->agent, 'event' => $r->event, 'ring_duration' => gmdate('H:i:s', (int) ($r->data1 / 1000)), 'time' => $r->time, ]; }); if (!empty($item->data5)) { $doneByUser = User::find($item->data5); $row->done_by = $doneByUser ? $doneByUser->name : 'Unknown'; } else { $row->done_by = null; } return $row; }); if ($result->isEmpty()) { return $this->respond(true, [ 'data' => [], 'filters' => [ 'daterange' => $conds['from'] . ' - ' . $conds['to'], ], ], [], 'No missed calls found'); } return $this->respond(true, [ 'data' => $result, 'filters' => [ 'daterange' => $conds['from'] . ' - ' . $conds['to'], 'queuename' => $request->queuename ?? null, 'search' => $request->search ?? null, ], 'options' => [ 'queues' => Group::where('company_id', $user->company->id ?? 1)->pluck('name', 'id'), ], ], [], 'Missed calls retrieved successfully'); } catch (\Throwable $th) { return $this->respond(false, [], ['error' => $th->getMessage()], 'Internal Server Error', 500); } } /** * GET /api/reporting/ring-details/{callid} * * Returns detailed ring history for a missed call (who rang and for how long). */ public function getRingDetails($callid) { try { // Same source & timing as MissedController::getRingDetails(): // resolve the missed call from inbound_reports first. $missedCall = InboundReport::where('callid', $callid) ->whereIn('hangup_type', ['ABANDON', 'EXITWITHTIMEOUT']) ->first(); if (!$missedCall) { return $this->respond(true, [ 'ring_details' => [], 'inbound_calls' => [], 'outbound_calls' => [], 'active_missed_calls' => [], 'ring_details_count' => 0, 'inbound_calls_count' => 0, 'outbound_calls_count' => 0, 'active_missed_calls_count' => 0, ], [], 'Ring details retrieved'); } $startTime = Carbon::parse($missedCall->call_time)->format('Y-m-d H:i:s'); $endTime = Carbon::parse($missedCall->call_time)->addSeconds($missedCall->ringtime ?? 0)->format('Y-m-d H:i:s'); // 1. Per-agent ring attempt details for the main call (granular queuelog data — // no equivalent stored in inbound_reports, so queuelog is still used) $ringDetails = DB::table('asteriskcdrdb.queuelog') ->select('agent', 'event', 'data1', 'time') ->where('callid', $callid) ->whereIn('event', ['RINGNOANSWER', 'RINGCANCELED']) ->where('data1', '>', 1000) ->orderBy('time', 'asc') ->get() ->map(function ($row) { $row->ring_duration = gmdate('H:i:s', (int) ($row->data1 / 1000)); unset($row->data1); return $row; }); // 2. Concurrent missed calls — from inbound_reports $activeMissedCalls = InboundReport::whereIn('hangup_type', ['ABANDON', 'EXITWITHTIMEOUT']) ->where('callid', '!=', $callid) ->where('call_time', '>=', $startTime) ->where('call_time', '<=', $endTime) ->orderBy('call_time', 'asc') ->get() ->map(fn($r) => (object) [ 'agent' => $r->agent, 'event' => $r->hangup_type, 'ring_duration' => gmdate('H:i:s', (int) ($r->ringtime ?? 0)), 'time' => Carbon::parse($r->call_time)->format('Y-m-d H:i:s'), ]); // 3. Concurrent answered inbound calls — from inbound_reports $inboundCalls = InboundReport::whereIn('hangup_type', ['COMPLETECALLER', 'COMPLETEAGENT']) ->where('call_time', '<=', $endTime) ->whereRaw('DATE_ADD(call_time, INTERVAL (ringtime + talktime) SECOND) >= ?', [$startTime]) ->orderBy('call_time', 'asc') ->get() ->map(fn($r) => (object) [ 'callid' => $r->callid, 'enter_time' => Carbon::parse($r->call_time)->format('Y-m-d H:i:s'), 'event' => $r->hangup_type, 'did' => $r->did, 'dstchannel' => null, 'src' => $r->caller_number, 'number' => $r->caller_number, 'queue_name' => $r->queue_description, 'total_duration' => gmdate('H:i:s', (int) (($r->ringtime ?? 0) + ($r->talktime ?? 0))), 'agent' => $r->agent, ]); // 4. Concurrent outbound calls — from outbound_reports $outboundCalls = OutboundReport::where('call_time', '<=', $endTime) ->whereRaw('DATE_ADD(call_time, INTERVAL duration SECOND) >= ?', [$startTime]) ->whereIn('disposition', ['ANSWERED', 'BUSY', 'NO ANSWER', 'FAILED']) ->orderBy('call_time', 'asc') ->get() ->map(fn($r) => (object) [ 'uniqueid' => $r->callid, 'calldate' => Carbon::parse($r->call_time)->format('Y-m-d H:i:s'), 'src' => $r->src, 'dst' => $r->called_number, 'duration' => gmdate('H:i:s', (int) ($r->duration ?? 0)), 'billsec' => gmdate('H:i:s', (int) ($r->talktime ?? 0)), 'ringtime' => gmdate('H:i:s', (int) ($r->ringtime ?? 0)), 'disposition' => $r->disposition, 'agent_name' => $r->agent_name, ]); return $this->respond(true, [ 'ring_details' => $ringDetails, 'inbound_calls' => $inboundCalls, 'outbound_calls' => $outboundCalls, 'active_missed_calls' => $activeMissedCalls, 'ring_details_count' => $ringDetails->count(), 'inbound_calls_count' => $inboundCalls->count(), 'outbound_calls_count' => $outboundCalls->count(), 'active_missed_calls_count' => $activeMissedCalls->count(), ], [], 'Ring details retrieved'); } catch (\Throwable $th) { return $this->respond(false, [], ['error' => $th->getMessage()], 'Internal Server Error', 500); } } /** * POST /api/reporting/missed-done/{num} * * Marks a missed call (by caller number) as handled by the current user. */ public function missedDone(Request $request, $num) { try { $userId = Auth::id(); /* * Normalize the input — same phone-format expansion as * MissedController::missedDone(). * * Examples: * +201002601172 * 201002601172 * 00201002601172 * 01002601172 * +20 100 260 1172 * 010-026-01172 */ $originalNum = $num; // Remove spaces, -, (, ), etc. but keep + $num = preg_replace('/[^0-9+]/', '', $num); $numbers = []; // Add original format. if (!empty($num)) { $numbers[] = $num; } // Remove + for processing. $cleanNum = ltrim($num, '+'); // CASE 1: 0020XXXXXXXXXX → 00201002601172, 201002601172, +201002601172, 01002601172 if (str_starts_with($cleanNum, '0020')) { $international = substr($cleanNum, 2); $numbers[] = $international; $numbers[] = '+' . $international; $numbers[] = '0' . substr($international, 2); // CASE 2: +20XXXXXXXXXX / 20XXXXXXXXXX → 201002601172, +201002601172, 00201002601172, 01002601172 } elseif (str_starts_with($cleanNum, '20')) { $international = $cleanNum; $numbers[] = $international; $numbers[] = '+' . $international; $numbers[] = '00' . $international; $numbers[] = '0' . substr($international, 2); // CASE 3: Egyptian local number (01002601172) → 01002601172, 201002601172, +201002601172, 00201002601172 } elseif (preg_match('/^01[0-9]{9}$/', $cleanNum)) { $international = '20' . substr($cleanNum, 1); $numbers[] = $cleanNum; $numbers[] = $international; $numbers[] = '+' . $international; $numbers[] = '00' . $international; } // Remove duplicate values and empty values. $numbers = array_values( array_unique( array_filter($numbers) ) ); Log::info('missedDone phone formats', [ 'original' => $originalNum, 'clean' => $cleanNum, 'numbers' => $numbers, 'userId' => $userId, ]); $placeholders = implode( ',', array_fill(0, count($numbers), '?') ); // QUEUELOG $sql1 = "UPDATE asteriskcdrdb.queuelog SET data5 = ? WHERE event = 'ENTERQUEUE' AND data2 IN ($placeholders) AND (data5 IS NULL OR data5 = '')"; $bindings1 = array_merge([$userId], $numbers); Log::info('missedDone queuelog SQL', [ 'sql' => $sql1, 'bindings' => $bindings1, ]); $affectedRows = DB::update($sql1, $bindings1); // INBOUND REPORTS $sql2 = "UPDATE inbound_reports SET data5 = ? WHERE event IN ('ABANDON', 'EXITWITHTIMEOUT') AND caller_number IN ($placeholders) AND (data5 IS NULL OR data5 = '')"; $bindings2 = array_merge([$userId], $numbers); Log::info('missedDone inbound_reports SQL', [ 'sql' => $sql2, 'bindings' => $bindings2, ]); DB::update($sql2, $bindings2); if ($affectedRows > 0) { return $this->respond(true, ['caller_number' => $num, 'done_by' => $userId], [], 'Missed call marked as done'); } return $this->respond(false, [], ['error' => 'Number not found or already marked'], 'Not Found', 404); } catch (\Throwable $th) { return $this->respond(false, [], ['error' => $th->getMessage()], 'Internal Server Error', 500); } } /** * GET /api/reporting/missed/stats * * Returns chart-ready counts for missed call types (ABANDON vs EXITWITHTIMEOUT) * and the breakdown of hangup reasons (status field). * Mirrors MissedController::callStats(). * * Filters (query params): * - daterange : "YYYY-MM-DD HH:mm:ss - YYYY-MM-DD HH:mm:ss". Defaults to today. */ public function callStats(Request $request) { try { $user = Auth::user(); $company_groups = collect($user->accessibleQueueIds()); $conds = $this->SettingsHelper->defaultConds(); if ($request->has('daterange')) { try { $range = explode(' - ', $request->daterange); $conds['from'] = Carbon::createFromFormat('Y-m-d H:i:s', trim($range[0]))->format('Y-m-d H:i:s'); $conds['to'] = Carbon::createFromFormat('Y-m-d H:i:s', trim($range[1]))->format('Y-m-d H:i:s'); } catch (\Exception $e) { $conds['from'] = Carbon::now()->startOfDay()->format('Y-m-d H:i:s'); $conds['to'] = Carbon::now()->endOfDay()->format('Y-m-d H:i:s'); } } // Same base query & logic as MissedController::callStats(): // inbound_reports — one row per missed call, already filtered. $baseQuery = InboundReport::whereBetween('call_time', [$conds['from'], $conds['to']]) ->whereIn('hangup_type', ['ABANDON', 'EXITWITHTIMEOUT']) ->whereIn('enter_queuename', $company_groups->toArray()); if ($user->access_to_all_entity != '1' && $user->groups->isNotEmpty()) { $baseQuery->whereIn('enter_queuename', $user->accessibleQueueIds()); } // Event-type breakdown (ABANDON vs EXITWITHTIMEOUT) $statusCounts = (clone $baseQuery) ->selectRaw('hangup_type as status, COUNT(*) as count') ->groupBy('hangup_type') ->orderByRaw('COUNT(*) DESC') ->get(); // Reason breakdown (the status / reason field per missed call) $reasonCounts = (clone $baseQuery) ->selectRaw("COALESCE(NULLIF(status, ''), 'Unknown') as status, COUNT(*) as count") ->groupBy('status') ->orderByRaw('COUNT(*) DESC') ->get(); return $this->respond(true, [ 'status_data' => [ 'labels' => $statusCounts->pluck('status')->toArray(), 'counts' => $statusCounts->pluck('count')->toArray(), ], 'reason_data' => [ 'labels' => $reasonCounts->map(fn($i) => $i->status ?? 'Unknown')->toArray(), 'counts' => $reasonCounts->pluck('count')->toArray(), ], 'total_missed' => (clone $baseQuery)->count(), 'filters' => [ 'daterange' => $conds['from'] . ' - ' . $conds['to'], ], ], [], 'Missed call stats retrieved successfully'); } catch (\Throwable $th) { return $this->respond(false, [], ['error' => $th->getMessage()], 'Internal Server Error', 500); } } }