resolveDateRange(); if (empty($dates)) { $this->error('Provide a date (e.g. 2026-07-01) or a range (--from= --to=).'); return 1; } $dryRun = (bool) $this->option('dry-run'); if ($dryRun) { $this->warn('DRY RUN — no data will be inserted.'); } $this->primeDefaults(); $totalProcessed = 0; $totalInserted = 0; $totalSkipped = 0; $totalFailed = 0; foreach ($dates as $date) { [$processed, $inserted, $skipped, $failed] = $this->processDate($date, $dryRun); $totalProcessed += $processed; $totalInserted += $inserted; $totalSkipped += $skipped; $totalFailed += $failed; } $this->line("Totals — Processed: {$totalProcessed} | Inserted: {$totalInserted} | Skipped (exists): {$totalSkipped} | Failed: {$totalFailed}"); Log::info('CDR rebuild completed', [ 'dates' => [reset($dates), end($dates)], 'processed' => $totalProcessed, 'inserted' => $totalInserted, 'skipped' => $totalSkipped, 'failed' => $totalFailed, ]); return 0; } private function resolveDateRange(): array { $date = $this->argument('date'); $from = $this->option('from'); $to = $this->option('to'); if ($date) { return [$date]; } if ($from && $to) { $start = Carbon::parse($from); $end = Carbon::parse($to); $dates = []; for ($d = $start; $d->lte($end); $d->addDay()) { $dates[] = $d->format('Y-m-d'); } return $dates; } return []; } private function primeDefaults(): void { $this->defaultSrc = DB::table('asteriskcdrdb.cdr') ->where('dcontext', 'from-internal') ->where('src', '!=', '') ->groupBy('src') ->orderByRaw('COUNT(*) DESC') ->value('src'); $this->defaultDid = DB::table('asteriskcdrdb.cdr') ->where('dcontext', 'ext-queues') ->where('did', '!=', '') ->groupBy('did') ->orderByRaw('COUNT(*) DESC') ->value('did'); if (!$this->defaultDid) { $this->defaultDid = DB::table('asteriskcdrdb.cdr') ->where('did', '!=', '') ->groupBy('did') ->orderByRaw('COUNT(*) DESC') ->value('did'); } } private function processDate(string $date, bool $dryRun): array { $this->info("Processing {$date}..."); $calls = $this->fetchCallsForDate($date); if ($calls->isEmpty()) { $this->warn("No calls found for {$date}"); return [0, 0, 0, 0]; } $processed = 0; $inserted = 0; $skipped = 0; $failed = 0; $linkedids = $calls->pluck('linkedid')->filter()->unique()->values()->all(); $existingLinkedids = DB::table('asteriskcdrdb.cdr') ->whereIn('linkedid', $linkedids) ->pluck('linkedid') ->unique() ->values() ->all(); $existingSet = array_flip($existingLinkedids); $rows = []; foreach ($calls as $call) { $processed++; if (!$call->linkedid || isset($existingSet[$call->linkedid])) { $skipped++; continue; } $record = $this->buildCdrRecord($call); if ($record === null) { $failed++; continue; } $rows[] = $record; $inserted++; } if (!empty($rows)) { $this->line(" {$date}: {$processed} processed, {$inserted} to insert, {$skipped} skipped, {$failed} failed"); if (!$dryRun) { DB::beginTransaction(); try { foreach (array_chunk($rows, 500) as $chunk) { DB::table('asteriskcdrdb.cdr')->insert($chunk); } DB::commit(); $this->line(" ✓ Inserted {$inserted} CDR records for {$date}"); } catch (\Exception $e) { DB::rollBack(); $this->error(" ✗ Failed to insert for {$date}: {$e->getMessage()}"); Log::error("CDR rebuild insert failed for {$date}: {$e->getMessage()}"); $failed = $processed - $inserted - $skipped; $inserted = 0; } } } else { $this->line(" {$date}: {$processed} processed, 0 to insert, {$skipped} skipped, {$failed} failed"); } return [$processed, $inserted, $skipped, $failed]; } private function fetchCallsForDate(string $date) { $startOfDay = Carbon::parse($date)->startOfDay(); $endOfDay = Carbon::parse($date)->endOfDay(); return Call::whereBetween('call_date', [$startOfDay, $endOfDay]) ->whereNotNull('linkedid') ->whereNotNull('direction_id') ->get(); } private function buildCdrRecord(Call $call): ?array { $direction = $this->resolveDirection($call->direction_id); if ($direction === null) { $this->warn("Call {$call->id}: unknown direction_id {$call->direction_id}, skipped"); return null; } $disposition = $this->resolveDisposition($call); $duration = (int) ($call->duration ?? 0); $billsec = (int) ($call->talktime ?? 0); $now = $call->call_date ?? now(); $record = [ 'calldate' => $now, 'clid' => '', 'src' => '', 'dst' => '', 'dcontext' => '', 'channel' => '', 'dstchannel' => '', 'lastapp' => '', 'lastdata' => '', 'duration' => $duration, 'billsec' => $billsec, 'disposition' => $disposition, 'amaflags' => 3, 'accountcode' => '', 'uniqueid' => $call->linkedid, 'userfield' => '', 'did' => '', 'recordingfile' => '', 'cnum' => '', 'cnam' => '', 'outbound_cnum' => '', 'outbound_cnam' => '', 'dst_cnam' => '', 'linkedid' => $call->linkedid, 'peeraccount' => '', 'sequence' => 0, ]; if ($direction === 'Outgoing') { $record['dcontext'] = 'from-internal'; $record['src'] = $this->inferOutboundSrc($call); $record['dst'] = $call->phone_number ?? ''; $record['cnum'] = (string) ($call->owned_by_id ?? ''); $record['cnam'] = $this->resolveUserName($call->owned_by_id); $record['lastapp'] = 'Dial'; $record['clid'] = $record['cnam'] ? "\"{$record['cnam']}\" <{$record['src']}>" : "\"\" <{$record['src']}>"; } elseif ($direction === 'Incoming') { $record['dcontext'] = 'ext-queues'; $record['src'] = $call->phone_number ?? ''; $record['dst'] = $this->inferInboundDst($call); $record['did'] = $this->inferDid($call); $record['cnum'] = $call->phone_number ?? ''; $record['cnam'] = $call->phone_number ?? ''; $record['lastapp'] = 'Queue'; if ($call->owned_by_id) { $record['dstchannel'] = "Local/{$call->owned_by_id}@from-queue-exten-only;1"; } $record['clid'] = $call->phone_number ? "\"{$call->phone_number}\" <{$call->phone_number}>" : ''; } else { $this->warn("Call {$call->id}: unrecognized direction '{$direction}', skipped"); return null; } return $record; } private function resolveDirection(int $directionId): ?string { if (!isset($this->directionCache[$directionId])) { $direction = DirectionType::find($directionId); $this->directionCache[$directionId] = $direction?->name; } return $this->directionCache[$directionId]; } private function resolveDisposition(Call $call): string { if ($call->status_id && !isset($this->dispositionCache[$call->status_id])) { $status = CallStatus::find($call->status_id); if ($status) { $parts = explode(' - ', $status->name); $last = end($parts); if (in_array($last, ['ANSWERED', 'BUSY', 'NO ANSWER', 'FAILED', 'CONGESTION'])) { $this->dispositionCache[$call->status_id] = $last; } } } if (isset($this->dispositionCache[$call->status_id])) { return $this->dispositionCache[$call->status_id]; } if ($call->hangup === 'agent') { return 'ANSWERED'; } if (($call->talktime ?? 0) > 0) { return 'ANSWERED'; } return 'NO ANSWER'; } private function resolveUserName(?int $userId): string { if ($userId === null || $userId === 0) { return ''; } if (!isset($this->userNameCache[$userId])) { $name = DB::table('users')->where('id', $userId)->value('name'); $this->userNameCache[$userId] = $name ?? ''; } return $this->userNameCache[$userId]; } private function inferOutboundSrc(Call $call): string { if ($call->owned_by_id) { $historical = DB::table('asteriskcdrdb.cdr') ->where('cnum', (string) $call->owned_by_id) ->where('dcontext', 'from-internal') ->where('src', '!=', '') ->groupBy('src') ->orderByRaw('COUNT(*) DESC') ->value('src'); if ($historical) { return $historical; } } return $this->defaultSrc ?? ''; } private function inferInboundDst(Call $call): string { if ($call->phone_number) { $historical = DB::table('asteriskcdrdb.cdr') ->where('src', $call->phone_number) ->where('dcontext', 'ext-queues') ->where('dst', '!=', '') ->groupBy('dst') ->orderByRaw('COUNT(*) DESC') ->value('dst'); if ($historical) { return $historical; } } $historicalDefault = DB::table('asteriskcdrdb.cdr') ->where('dcontext', 'ext-queues') ->where('dst', '!=', '') ->groupBy('dst') ->orderByRaw('COUNT(*) DESC') ->value('dst'); return $historicalDefault ?? ''; } private function inferDid(Call $call): string { if ($call->phone_number) { $historical = DB::table('asteriskcdrdb.cdr') ->where('src', $call->phone_number) ->where('dcontext', 'ext-queues') ->where('did', '!=', '') ->groupBy('did') ->orderByRaw('COUNT(*) DESC') ->value('did'); if ($historical) { return $historical; } } return $this->defaultDid ?? ''; } }