latest lead id. * * null means "known to have no matching lead". Reusing it across chunks * avoids re-scanning the leads table for numbers that repeat in the call * logs, while RAM stays capped at numberCacheMax entries no matter how * large the leads table is. * * @var array */ private array $numberCache = []; /** Maximum entries kept in $numberCache (FIFO eviction). */ private int $numberCacheMax = 5000; /** * Execute the console command. * * @return int */ public function handle(): int { $this->countryCode = config('sms.country_code', '20'); $this->trunkPrefix = config('sms.trunk_prefix', '0'); $batch = max(1, (int) $this->option('batch')); $dryRun = (bool) $this->option('dry-run'); if ($dryRun) { $this->warn('DRY RUN — no changes will be saved.'); } if (!Lead::query()->exists()) { $this->info('No leads available to match against.'); return 0; } $matched = 0; $unmatched = 0; $skipped = 0; $processed = 0; // Keyset-paginate every unmatched record in id order. Memory stays // bounded to one chunk at a time regardless of the table size, and // because we page by id (and only ever mutate lead_id, never id) rows // already processed can never be re-fetched. CallLog::whereNull('lead_id') ->whereNotNull('phone_number') ->chunkById($batch, function ($callLogs) use ( &$matched, &$unmatched, &$skipped, &$processed, $dryRun ) { // Pass 1 — normalize every record and collect the distinct // numbers of this chunk that aren't already in the cache. $pending = []; // callLogId => [normalized, original] $unseen = []; // normalized => true foreach ($callLogs as $callLog) { $processed++; // Normalize the stored call-log number to Egyptian local format. $normalized = PhoneNormalizer::toLocal( $callLog->phone_number, $this->countryCode, $this->trunkPrefix ); if (!$normalized) { $skipped++; continue; } $pending[$callLog->id] = [$normalized, $callLog->phone_number]; if (!array_key_exists($normalized, $this->numberCache)) { $unseen[$normalized] = true; } } // Resolve every unseen number of this chunk with batched DB // queries, then merge the results into the bounded cache. if ($unseen !== []) { $resolved = $this->resolveLatestLeadIds(array_keys($unseen)); foreach (array_keys($unseen) as $number) { $this->rememberNumber($number, $resolved[$number] ?? null); } } // Pass 2 — assign matches and keep the counters. $updates = []; foreach ($pending as $callLogId => [$normalized, $original]) { $leadId = $this->numberCache[$normalized] ?? null; if (!$leadId) { $unmatched++; if ($dryRun) { $this->line("[dry-run] call_log #{$callLogId} ({$original} => {$normalized}) — no matching lead"); } continue; } $matched++; if ($dryRun) { $this->line("[dry-run] call_log #{$callLogId} ({$original} => {$normalized}) -> lead #{$leadId}"); continue; } $updates[$callLogId] = $leadId; } // Apply this chunk's matches in a single transaction via the // query builder (no Eloquent hydration / events) for speed. if ($updates !== [] && !$dryRun) { DB::transaction(function () use ($updates) { foreach ($updates as $id => $leadId) { DB::table('call_logs') ->where('id', $id) ->update([ 'lead_id' => $leadId, 'updated_at' => now(), ]); } }); } $this->line(sprintf( 'Chunk done — processed: %d | matched: %d | unmatched: %d | skipped: %d | cached numbers: %d', $processed, $matched, $unmatched, $skipped, count($this->numberCache) )); }); $this->line(sprintf( 'Processed %d call_logs — matched: %d | unmatched: %d | skipped (unnormalizable): %d%s', $processed, $matched, $unmatched, $skipped, $dryRun ? ' (DRY RUN)' : '' )); if (!$dryRun) { Log::info('call-logs:sync-lead-id completed', [ 'processed' => $processed, 'matched' => $matched, 'unmatched' => $unmatched, 'skipped' => $skipped, ]); } return 0; } /** * Resolve the latest lead id for each normalized number using batched DB * queries — never loading the whole leads table into memory. * * A trailing-digit prefilter (shared by all common Egyptian * representations: 01283932691 / 201283932691 / +201283932691) narrows * the candidates; each candidate is then verified by normalizing its * stored phone/mobile through PhoneNormalizer. Candidates are walked * with chunkById, so RAM is bounded to a few hundred rows no matter how * many leads match. The highest id wins per number, mirroring the * previous orderBy('id', 'desc') logic. * * @param array $numbers distinct normalized numbers * @return array normalized number => latest lead id */ private function resolveLatestLeadIds(array $numbers): array { $resolved = []; if ($numbers === []) { return $resolved; } // Bound the size of each generated OR-chain no matter what --batch is. foreach (array_chunk(array_values($numbers), 500) as $group) { $tails = array_map(fn (string $n): string => substr($n, -10), $group); $targets = array_fill_keys($group, true); Lead::query() ->select(['id', 'phone', 'mobile']) ->where(function ($query) use ($tails) { foreach ($tails as $i => $tail) { $groupClause = function ($q) use ($tail) { $q->where('phone', 'like', '%' . $tail) ->orWhere('mobile', 'like', '%' . $tail); }; if ($i === 0) { $query->where($groupClause); } else { $query->orWhere($groupClause); } } }) ->chunkById(1000, function ($candidates) use (&$resolved, $targets) { foreach ($candidates as $lead) { foreach ([$lead->phone, $lead->mobile] as $field) { if (!$field) { continue; } $normalized = PhoneNormalizer::toLocal( $field, $this->countryCode, $this->trunkPrefix ); if (!$normalized || !isset($targets[$normalized])) { continue; } // Highest id wins => most recently inserted lead. if (($resolved[$normalized] ?? 0) < $lead->id) { $resolved[$normalized] = $lead->id; } } } }); } return $resolved; } /** * Store a normalized number's resolution in the bounded FIFO cache. */ private function rememberNumber(string $normalized, ?int $leadId): void { if (array_key_exists($normalized, $this->numberCache)) { return; } if (count($this->numberCache) >= $this->numberCacheMax) { reset($this->numberCache); $oldest = key($this->numberCache); unset($this->numberCache[$oldest]); } $this->numberCache[$normalized] = $leadId; } }