'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; protected $UserHelper; public function __construct() { parent::__construct(); $this->SettingsHelper = new SettingsHelper(); $this->UserHelper = new UserHelper(); } /** * GET /api/reporting/inbound * * Returns paginated inbound (answered queue) calls. * * Filters (query params): * - daterange : "YYYY-MM-DD HH:mm:ss - YYYY-MM-DD HH:mm:ss" * - did_ext : DID / incoming extension * - transfers_only : "1" to return only calls that were transferred * - user_ext : Agent user ID * - group_ext : Queue / group ID * - search : Free-text search (queue description, agent, event, caller number) * - per_page : Items per page (default 10) * - page : Page number * * Filtering, permission scoping and data source mirror * Modules\Reporting\Http\Controllers\InboundController::inbound(). */ public function inbound(Request $request) { try { $user = Auth::user(); $conds = $this->SettingsHelper->defaultConds(); if ($request->has('daterange')) { $range = explode(' - ', $request->daterange); try { $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'); } } // Determine company groups visible to the current user $company_groups = $user->accessibleQueueIds(); // Get admins to exclude from non-manager views $admins = User::where('company_id', $user->company_id) ->whereHas('roles', fn($q) => $q->where('name', 'company_manager')) ->pluck('id') ->toArray(); // Base query using InboundReport model (same source as web module). // The web list filters answered calls through `call_status`, not // `hangup_type`, so we mirror that here. $query = InboundReport::whereBetween('call_time', [$conds['from'], $conds['to']]) ->where('call_status', 'answered') ->whereIn('enter_queuename', $company_groups); // --- Filters (mirror InboundController::inbound) --- if (isset($request->did_ext)) { $query->where('did', $request->did_ext); } if ($request->get('transfers_only') == '1') { $query->where('transfers_count', '>', 0); } // API-only narrowing filters that follow the same permission scope. if ($request->filled('group_ext')) { $query->where('enter_queuename', $request->group_ext); } // Accept both the plain `search` param and DataTables' `search[value]`. $searchValue = $request->input('search.value'); if (empty($searchValue)) { $searchValue = $request->input('search'); } $searchValue = strtolower(trim((string) $searchValue)); if ($searchValue !== '') { $query->where(function ($q) use ($searchValue) { $q->orWhere(DB::raw('LOWER(queue_description)'), 'LIKE', "%{$searchValue}%") ->orWhere(DB::raw('LOWER(agent)'), 'LIKE', "%{$searchValue}%") ->orWhere(DB::raw('LOWER(event)'), 'LIKE', "%{$searchValue}%") ->orWhere(DB::raw('LOWER(caller_number)'), 'LIKE', "%{$searchValue}%"); }); } // --- Role-based access (mirrors web) --- if ($user->roles[0]->name !== 'company_manager') { $query->whereNotIn('agent', array_map('strval', $admins)); } if ($user->access_to_all_entity != '1') { $visibleGroups = $user->accessibleQueueIds(); if (!empty($visibleGroups)) { $query->whereIn('enter_queuename', $visibleGroups); } if ($user->roles[0]->name === 'user') { $query->where('agent', 'LIKE', "%{$user->name}%"); } } if ($request->filled('user_ext')) { $agentUser = User::find($request->user_ext); if ($agentUser) { $query->where('agent', 'LIKE', "%{$agentUser->name}%"); } } $query->orderBy('call_time', 'desc'); $perPage = (int) $request->get('per_page', 10); $data = $query->paginate($perPage); $data->getCollection()->transform(function ($item) { $data5 = $item->getRawOriginal('data5'); $item->mergeCasts(['call_time' => 'string', 'ringtime' => 'string', 'talktime' => 'string']); $item->call_time = date('Y-m-d h:i:s A', strtotime($item->getRawOriginal('call_time'))); $item->ring_time = gmdate('H:i:s', (int) $item->getRawOriginal('ringtime')); $item->talk_time = gmdate('H:i:s', (int) $item->getRawOriginal('talktime')); $item->hangup_by = $item->hangup_type === 'COMPLETECALLER' ? 'Caller' : 'Agent'; unset($item->ringtime, $item->talktime, $item->hangup_type, $item->id, $item->user_id, $item->company_id, $item->event, $item->data5, $item->report_date, $item->status, $item->call_status, $item->callid, $item->created_at, $item->updated_at); if (Auth::user()->hasPermissionTo('reporting.listen') && !empty($item->linkedid)) { $item->listen_url = url("api/reporting/listen/{$item->linkedid}"); $item->listen_status = empty($data5) ? 'pending' : 'done'; } else { $item->listen_url = null; $item->listen_status = 'not_available'; } return $item; }); return $this->respond(true, [ 'data' => $data, 'filters' => [ 'daterange' => $conds['from'] . ' - ' . $conds['to'], 'did_ext' => $request->did_ext ?? null, 'user_ext' => $request->user_ext ?? null, 'group_ext' => $request->group_ext ?? null, 'search' => $request->search ?? null, ], ], [], 'Inbound calls retrieved successfully'); } catch (\Throwable $th) { return $this->respond(false, [], ['error' => $th->getMessage()], 'Internal Server Error', 500); } } /** * GET /api/reporting/inbound/{linkedid}/transfers * * Returns transfer history for an inbound call. */ public function getTransfers($linkedid) { try { // All CDR rows for this call, ordered chronologically $cdrRows = DB::table('asteriskcdrdb.cdr') ->where('linkedid', $linkedid) ->orderBy('calldate', 'asc') ->orderBy('sequence', 'asc') ->get(); if ($cdrRows->isEmpty()) { return $this->respond(true, ['transfers' => [], 'count' => 0], [], 'Transfer data retrieved'); } // The initial agent answer = first ANSWERED Dial (the agent who originally picked up) $initialDial = $cdrRows->where('lastapp', 'Dial')->where('disposition', 'ANSWERED')->first(); if (!$initialDial) { return $this->respond(true, ['transfers' => [], 'count' => 0], [], 'Transfer data retrieved'); } $initialUniqueId = $initialDial->uniqueid; // -- 1. Queue-to-queue transfers -- // CDR rows where lastapp='Queue' AND uniqueid != linkedid // (the original queue call has uniqueid = linkedid) $queueTransferRows = $cdrRows ->where('lastapp', 'Queue') ->filter(fn($r) => $r->uniqueid !== $linkedid) ->sortBy('calldate') ->values(); $knownUniqueIds = [$linkedid, $initialUniqueId]; $transfers = []; foreach ($queueTransferRows as $qt) { $queueExt = trim(explode(',', $qt->lastdata)[0] ?? ''); // The transfer agent comes from a NEW channel (different uniqueid) // that dials after the Queue row was created $agentDial = $cdrRows ->where('lastapp', 'Dial') ->filter(fn($r) => !in_array($r->uniqueid, $knownUniqueIds) && $r->calldate >= $qt->calldate ) ->sortBy('calldate') ->first(); if ($agentDial) { $knownUniqueIds[] = $agentDial->uniqueid; } $transfers[] = [ '_type' => 'queue', 'calldate' => $qt->calldate, 'queue_ext' => $queueExt, 'agent_ext' => $agentDial?->dst, 'disposition' => $agentDial?->disposition ?? 'NO ANSWER', 'duration' => $agentDial?->duration ?? 0, ]; } // -- 2. Direct extension transfers -- // Additional Dial rows within the SAME uniqueid as the initial agent answer. $initialChannelDials = $cdrRows ->where('lastapp', 'Dial') ->where('uniqueid', $initialUniqueId) ->sortBy('calldate') ->values() ->slice(1); // skip the initial answer itself foreach ($initialChannelDials as $dialRow) { // Skip if already covered by a queue transfer (queue row at same time) $coveredByQueue = $queueTransferRows ->filter(fn($qt) => $qt->calldate <= $dialRow->calldate) ->isNotEmpty(); if ($coveredByQueue) { continue; } // Extract the actual extension from dstchannel (e.g. PJSIP/998-xxx -> 998) $ext = null; if (!empty($dialRow->dstchannel)) { preg_match('/[\/:](\d{3,6})[-@]/', $dialRow->dstchannel, $m); $ext = $m[1] ?? null; } $ext = $ext ?? $dialRow->dst; if (substr($ext, 0, 2) == '99' && strlen($ext) > 4) { $ext = substr($ext, 2); } $transfers[] = [ '_type' => 'direct', 'calldate' => $dialRow->calldate, 'queue_ext' => null, 'agent_ext' => $ext, 'disposition' => $dialRow->disposition, 'duration' => $dialRow->duration ?? 0, ]; } if (empty($transfers)) { return $this->respond(true, ['transfers' => [], 'count' => 0], [], 'Transfer data retrieved'); } usort($transfers, fn($a, $b) => strcmp($a['calldate'], $b['calldate'])); // Enrich: queue descriptions $queueExts = array_values(array_filter(array_unique(array_column($transfers, 'queue_ext')))); $queueDescriptions = []; if (!empty($queueExts)) { $queueDescriptions = DB::table('asterisk.queues_config') ->whereIn('extension', $queueExts) ->pluck('descr', 'extension') ->toArray(); } // Enrich: agent names $agentExts = array_values(array_filter(array_unique(array_column($transfers, 'agent_ext')))); $agentNames = []; if (!empty($agentExts)) { $agentNames = User::whereIn('id', $agentExts) ->pluck('name', 'id') ->toArray(); } // Build response (same shape as the web transfer modal) $result = array_map(fn($t) => [ 'calldate' => $t['calldate'], 'queue_ext' => $t['queue_ext'], 'queue_name' => $t['queue_ext'] ? ($queueDescriptions[$t['queue_ext']] ?? $t['queue_ext']) : 'Internal Transfer', 'user_extension' => $t['agent_ext'], 'user_name' => $t['agent_ext'] ? ($agentNames[$t['agent_ext']] ?? $t['agent_ext']) : null, 'disposition' => $t['disposition'], 'duration' => $t['duration'], ], $transfers); return $this->respond(true, [ 'transfers' => array_values($result), 'count' => count($result), ], [], 'Transfer data retrieved'); } catch (\Throwable $th) { return $this->respond(false, [], ['error' => $th->getMessage()], 'Internal Server Error', 500); } } /** * POST /api/reporting/inbound/done/{id} * * Marks an inbound call as done (sets data5 = 1 on queuelog and inbound_reports). * Mirrors InboundController::inDone(). * * Path Parameters: * - id : The linkedid of the call */ public function inDone($id) { try { DB::update("UPDATE asteriskcdrdb.queuelog SET data5 = 1 WHERE id = ?", [$id]); DB::update("UPDATE inbound_reports SET data5 = 1 WHERE linkedid = ?", [$id]); return $this->respond(true, ['id' => $id], [], 'Inbound call marked as done'); } catch (\Throwable $th) { return $this->respond(false, [], ['error' => $th->getMessage()], 'Internal Server Error', 500); } } public function inDist(Request $request) { try { $conds = $this->SettingsHelper->defaultConds(); if ($request->has('daterange')) { $range = explode(' - ', $request->daterange); try { $conds['from'] = Carbon::createFromFormat('Y-m-d H:i:s', $range[0])->format('Y-m-d H:i:s'); $conds['to'] = Carbon::createFromFormat('Y-m-d H:i:s', $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'); } } // Fetch distribution data. // group_ext uses the same name and semantics as the Inbound // endpoint (narrows to one accessible queue). $data = $this->getInDistribution([ 'conds' => $conds, 'filter_queue' => $request->input('group_ext'), ]); // Build the response payload $response = [ 'months' => [ 'labels' => array_values($data['months_processed']), 'data' => $data['months_table'], 'chart_data' => [ 'answered' => array_values($data['answered_by_month']), 'missed' => array_values($data['unans_by_month']) ] ], 'days' => [ 'labels' => array_values($data['days_processed']), 'data' => $data['days_table'], 'chart_data' => [ 'answered' => array_values($data['answered_by_day']), 'missed' => array_values($data['unans_by_day']) ] ], 'hours' => [ 'labels' => array_values($data['hours_processed']), 'data' => $data['hourse_table'], 'chart_data' => [ 'answered' => array_values($data['answered_by_hour']), 'missed' => array_values($data['unans_by_hour']) ] ], 'summary' => [ 'total_answered' => $data['answered'], 'total_missed' => $data['unanswered'], 'total_calls' => $data['answered'] + $data['unanswered'], 'missed_percentage' => ($data['unanswered'] > 0 && $data['answered'] > 0) ? round(($data['unanswered'] / $data['answered']) * 100, 2) : 0 ], 'filters' => [ 'daterange' => $conds['from'] . ' - ' . $conds['to'], 'group_ext' => $request->input('group_ext'), ] ]; return $this->respond(true, $response, [], 'Inbound distribution data retrieved successfully'); } catch (\Throwable $th) { return $this->respond(false, [], ['error' => $th->getMessage()], 'Failed to retrieve inbound distribution data', 500); } } private function getInDistribution($data = array()) { $conds = $data['conds']; $user = Auth::user(); // Fetch once and reuse: accessibleQueueIds() runs permission and group // queries on every call. $company_groups = $user->accessibleQueueIds(); $query = DB::table('inbound_reports') ->select( 'call_time as time', 'enter_queuename as queuename', 'agent', 'call_status' ) ->whereBetween('call_time', [$conds['from'], $conds['to']]) ->whereIn('enter_queuename', $company_groups) ->whereIn('call_status', ['answered', 'missed']); $excludedQueues = CompanySettings::get('report_excluded_groups'); if (!empty($excludedQueues) && is_array($excludedQueues)) { $query->whereNotIn('enter_queuename', $excludedQueues); } if (!empty($data['filter_queue'])) { // (array) cast: group_ext arrives as a single scalar id from the // request, and whereIn() requires an array (a scalar would fatal in // cleanBindings()). Also accepts an array unchanged. $query->whereIn('enter_queuename', (array) $data['filter_queue']); } // Permission scoping (same as the web distribution). if ($user->roles[0]->name != 'company_manager') { // Lazy: only queried when the exclusion is actually applied. $admins = User::where('company_id', $user->company_id) ->whereHas('roles', function ($q) { $q->where('name', 'company_manager'); }) ->pluck('name') ->toArray(); $query->whereNotIn('agent', $admins); } if ($user->access_to_all_entity != '1') { // Queue restriction is already enforced above via $company_groups // (same accessibleQueueIds() result) — only the per-agent // restriction remains here. if ($user->roles[0]->name == 'user') { $query->where('agent', 'LIKE', "%{$user->name}%"); } } $return_array = [ 'days_processed' => [], 'hours_processed' => [], 'months_processed' => [], 'months_table' => [], 'days_table' => [], 'hourse_table' => [], 'answered_by_month' => [], 'answered_by_day' => [], 'answered_by_hour' => [], 'unans_by_month' => [], 'unans_by_day' => [], 'unans_by_hour' => [], 'unanswered' => 0, 'answered' => 0, ]; $results = $query->orderBy('time', 'ASC')->get(); if ($results->isNotEmpty()) { $countMonths = 0; $pervMonth = null; $countDays = 0; $pervDay = null; foreach ($results as $row) { $time_exploded = explode(" ", $row->time); $one_day = trim($time_exploded[0]); // Process days if (!in_array($one_day, $return_array['days_processed'])) { if ($countDays != 0) { $_ans = $return_array['answered_by_day'][$pervDay] ?? 0; $_mis = $return_array['unans_by_day'][$pervDay] ?? 0; $_tot = $_ans + $_mis; $return_array['days_table'][] = [ 'day' => $pervDay, 'answord' => $_ans, 'missed' => $_mis, 'offered' => $_tot, 'rate' => $_tot > 0 ? round(($_ans / $_tot) * 100, 1) . ' %' : '0 %', ]; } $return_array['days_processed'][] = $one_day; $pervDay = $one_day; $countDays++; } // Process hours $hour_exploded = explode(":", $time_exploded[1]); $one_hour = $hour_exploded[0]; if (!in_array($one_hour, $return_array['hours_processed'])) { $return_array['hours_processed'][] = $one_hour; } // Process months $month_year_label = Carbon::parse($row->time)->format('m-Y'); if (!in_array($month_year_label, $return_array['months_processed'])) { if ($countMonths != 0) { $_ans = $return_array['answered_by_month'][$pervMonth] ?? 0; $_mis = $return_array['unans_by_month'][$pervMonth] ?? 0; $_tot = $_ans + $_mis; $return_array['months_table'][] = [ 'month' => $pervMonth, 'answord' => $_ans, 'missed' => $_mis, 'offered' => $_tot, 'rate' => $_tot > 0 ? round(($_ans / $_tot) * 100, 1) . ' %' : '0 %', ]; } $return_array['months_processed'][] = $month_year_label; $pervMonth = $month_year_label; $countMonths++; } // Classify by call_status if ($row->call_status === 'missed') { $return_array['unanswered']++; $return_array['unans_by_day'][$one_day] = ($return_array['unans_by_day'][$one_day] ?? 0) + 1; $return_array['unans_by_hour'][$one_hour] = ($return_array['unans_by_hour'][$one_hour] ?? 0) + 1; $return_array['unans_by_month'][$month_year_label] = ($return_array['unans_by_month'][$month_year_label] ?? 0) + 1; } if ($row->call_status === 'answered') { $return_array['answered']++; $return_array['answered_by_day'][$one_day] = ($return_array['answered_by_day'][$one_day] ?? 0) + 1; $return_array['answered_by_hour'][$one_hour] = ($return_array['answered_by_hour'][$one_hour] ?? 0) + 1; $return_array['answered_by_month'][$month_year_label] = ($return_array['answered_by_month'][$month_year_label] ?? 0) + 1; } } // Add last month and day $_ans = $return_array['answered_by_month'][$month_year_label] ?? 0; $_mis = $return_array['unans_by_month'][$month_year_label] ?? 0; $_tot = $_ans + $_mis; $return_array['months_table'][] = [ 'month' => $month_year_label, 'answord' => $_ans, 'missed' => $_mis, 'offered' => $_tot, 'rate' => $_tot > 0 ? round(($_ans / $_tot) * 100, 1) . ' %' : '0 %', ]; $_ans = $return_array['answered_by_day'][$one_day] ?? 0; $_mis = $return_array['unans_by_day'][$one_day] ?? 0; $_tot = $_ans + $_mis; $return_array['days_table'][] = [ 'day' => $one_day, 'answord' => $_ans, 'missed' => $_mis, 'offered' => $_tot, 'rate' => $_tot > 0 ? round(($_ans / $_tot) * 100, 1) . ' %' : '0 %', ]; // Process hours table foreach ($return_array['hours_processed'] as $hour) { $_ans = $return_array['answered_by_hour'][$hour] ?? 0; $_mis = $return_array['unans_by_hour'][$hour] ?? 0; $_tot = $_ans + $_mis; $return_array['hourse_table'][] = [ 'hourse' => $hour, 'answord' => $_ans, 'missed' => $_mis, 'offered' => $_tot, 'rate' => $_tot > 0 ? round(($_ans / $_tot) * 100, 1) . ' %' : '0 %', ]; } // Sort hours sort($return_array['hours_processed']); } else { $return_array['answered'] = 0; $return_array['unanswered'] = 0; } return $return_array; } }