[ 'processed' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0, ], 'events' => [ 'processed' => 0, 'created' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0, ], ]; $handled = 0; Log::info('GeocodeEventLocations: start', ['limit' => $this->limit]); // first, geocode any location rows missing coordinates $locations = Location::query() ->whereNotNull('raw_address') ->where('raw_address', '<>', '') ->where(function ($q) { $q->whereNull('lat')->orWhereNull('lon'); }) ->orderBy('id'); $stop = false; $locations->chunkById(200, function ($chunk) use ($geocoder, &$stats, &$handled, &$stop) { foreach ($chunk as $loc) { if ($stop) { return false; } if ($this->limit !== null && $handled >= $this->limit) { $stop = true; return false; } $handled++; $stats['locations']['processed']++; try { $norm = $geocoder->forward($loc->raw_address); if (!$norm || !is_numeric($norm['lat'] ?? null) || !is_numeric($norm['lon'] ?? null)) { $stats['locations']['skipped']++; continue; } $changed = false; if ($loc->lat === null && is_numeric($norm['lat'])) { $loc->lat = $norm['lat']; $changed = true; } if ($loc->lon === null && is_numeric($norm['lon'])) { $loc->lon = $norm['lon']; $changed = true; } foreach (['street', 'city', 'state', 'postal', 'country'] as $field) { if (empty($loc->{$field}) && !empty($norm[$field])) { $loc->{$field} = $norm[$field]; $changed = true; } } if ($changed) { $loc->save(); $stats['locations']['updated']++; } else { $stats['locations']['skipped']++; } } catch (\Throwable $e) { $stats['locations']['failed']++; Log::warning('GeocodeEventLocations: location failed', [ 'location_id' => $loc->id, 'raw_address' => $loc->raw_address, 'error' => $e->getMessage(), ]); } } }, 'id'); if ($stop) { Log::info('GeocodeEventLocations: done', $stats); return; } // events that have a non-empty location string but no linked location row yet $todo = EventMeta::query() ->whereNull('location_id') ->whereNotNull('location') ->where('location', '<>', '') ->orderBy('event_id'); // important for chunkById // log total to process (before limit) $total = (clone $todo)->count(); Log::info('[geo] starting GeocodeEventLocations', ['total' => $total, 'limit' => $this->limit]); // chunk through event_meta rows $todo->chunkById(200, function ($chunk) use ($geocoder, &$stats, &$handled, &$stop) { foreach ($chunk as $meta) { if ($stop) { return false; // stop further chunking } // respect limit if provided if ($this->limit !== null && $handled >= $this->limit) { $stop = true; return false; } try { // geocode the free-form location string; prefer an existing location match $query = $meta->location; $location = Location::where('display_name', $meta->location) ->orWhere('raw_address', $meta->location) ->first(); if (!$location) { // soft match on prefix when there is exactly one candidate $matches = Location::where('display_name', 'like', $meta->location . '%') ->limit(2) ->get(); if ($matches->count() === 1) { $location = $matches->first(); } } if ($location) { // if we already have coords, just link and move on if (is_numeric($location->lat) && is_numeric($location->lon)) { $meta->location_id = $location->id; $meta->save(); $handled++; $stats['events']['processed']++; $stats['events']['updated']++; continue; } if ($location->raw_address) { $query = $location->raw_address; } } $norm = $geocoder->forward($query); // skip obvious non-address labels or unresolved queries if (!$norm || (!$norm['lat'] && !$norm['street'])) { $stats['events']['skipped']++; $handled++; $stats['events']['processed']++; Log::info('GeocodeEventLocations: skipped', [ 'event_id' => $meta->event_id, 'location' => $meta->location, 'query' => $query, ]); continue; } // normalized match key to reduce duplicates $lookup = [ 'display_name' => $norm['display_name'], 'street' => $norm['street'], 'city' => $norm['city'], 'state' => $norm['state'], 'postal' => $norm['postal'], 'country' => $norm['country'], ]; // try to match an existing location (by normalized fields) $existing = Location::where($lookup)->first(); // fall back to raw string match against any pre-seeded label rows if (!$existing && $meta->location) { $existing = Location::where('display_name', $meta->location) ->orWhere('raw_address', $meta->location) ->first(); } // reuse existing location or create a new one with coords $loc = $existing ?? Location::firstOrCreate( $lookup, [ 'raw_address' => $norm['raw_address'], 'lat' => $norm['lat'], 'lon' => $norm['lon'], ] ); // if we matched an existing row missing coords, backfill once if ($existing && (is_null($existing->lat) || is_null($existing->lon))) { $existing->lat = $norm['lat']; $existing->lon = $norm['lon']; $existing->raw_address ??= $norm['raw_address']; $existing->save(); $stats['events']['updated']++; } if ($loc->wasRecentlyCreated) { $stats['events']['created']++; } // link event_meta → locations $meta->location_id = $loc->id; $meta->save(); $handled++; $stats['events']['processed']++; } catch (\Throwable $e) { $stats['events']['failed']++; $handled++; $stats['events']['processed']++; Log::warning('GeocodeEventLocations: failed', [ 'event_id' => $meta->event_id, 'location' => $meta->location, 'error' => $e->getMessage(), ]); } } }, 'event_id'); Log::info('GeocodeEventLocations: done', $stats); } }