Skip to content
Merged
115 changes: 115 additions & 0 deletions app/Console/Commands/EnrichGeoLocations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<?php

namespace App\Console\Commands;

use App\Models\GeoLocation;
use App\Services\GeoLocationEnricher;
use Illuminate\Console\Command;

class EnrichGeoLocations extends Command
{
protected $signature = 'coconut:enrich-geo-locations
{--dry-run : Report only, no writes}
{--rules-only : Skip Nominatim geocoding}
{--geocode-only : Only geocode rows missing country_code}
{--force : Re-enrich even if country_code is set}
{--limit= : Cap rows processed (for testing)}';

protected $description = 'Extract country metadata from geo location names using rules and Nominatim geocoding';

public function handle(GeoLocationEnricher $enricher): int
{
$dryRun = (bool) $this->option('dry-run');
$rulesOnly = (bool) $this->option('rules-only');
$geocodeOnly = (bool) $this->option('geocode-only');
$force = (bool) $this->option('force');
$limit = $this->option('limit') !== null ? (int) $this->option('limit') : null;

$query = GeoLocation::query()->orderBy('id');

if (! $force) {
$query->whereNull('country_code');
}

if ($limit !== null) {
$query->limit($limit);
}

$records = $query->get();
$total = $records->count();

if ($total === 0) {
$this->info('No geo locations to process.');

return self::SUCCESS;
}

$this->info("Processing {$total} geo location(s)...");

$stats = [
'segment' => 0,
'whole' => 0,
'parens_mx' => 0,
'substring' => 0,
'geocoded' => 0,
'failed' => 0,
'skipped' => 0,
];

$allowGeocoding = ! $rulesOnly;

$bar = $this->output->createProgressBar($total);
$bar->start();

foreach ($records as $geoLocation) {
if (! $force && filled($geoLocation->country_code)) {
$stats['skipped']++;
$bar->advance();

continue;
}

if ($dryRun) {
$result = $enricher->resolve(
$geoLocation->name,
allowGeocoding: $allowGeocoding,
geocodeOnly: $geocodeOnly,
);
} else {
$result = $enricher->enrich(
$geoLocation,
allowGeocoding: $allowGeocoding,
force: $force,
geocodeOnly: $geocodeOnly,
);
}

if ($result !== null) {
$method = $result->method === 'geocoded' ? 'geocoded' : $result->method;
$stats[$method] = ($stats[$method] ?? 0) + 1;
} else {
$stats['failed']++;
}

$bar->advance();
}

$bar->finish();
$this->newLine(2);

$matched = $stats['segment'] + $stats['whole'] + $stats['parens_mx'] + $stats['substring'] + $stats['geocoded'];

$this->table(
['Method', 'Count'],
collect($stats)->map(fn (int $count, string $method) => [$method, $count])->values()->all(),
);

$this->info("Matched: {$matched} / {$total}");

if ($dryRun) {
$this->warn('Dry run only — no records were updated.');
}

return self::SUCCESS;
}
}
45 changes: 6 additions & 39 deletions app/Console/Commands/ImportOrganismMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Console\Commands;

use App\Models\Entry;
use App\Services\GeoLocationService;
use Illuminate\Console\Command;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
Expand Down Expand Up @@ -1300,45 +1301,11 @@ protected function findOrCreateGeoLocation(string $name): ?int
return null;
}

if (isset($this->geoLocationCache[$name])) {
return $this->geoLocationCache[$name];
}

$existing = DB::selectOne('SELECT id FROM geo_locations WHERE name = ?', [$name]);
if ($existing) {
$this->geoLocationCache[$name] = $existing->id;

return $existing->id;
}

try {
$id = DB::table('geo_locations')->insertGetId([
'name' => $name,
'created_at' => now(),
'updated_at' => now(),
]);
$this->geoLocationCache[$name] = $id;

return $id;
} catch (QueryException $e) {
if (! $this->isUniqueViolation($e)) {
Log::error('Unexpected DB error in geo_locations insert (not unique violation)', [
'sqlstate' => $e->errorInfo[0] ?? null,
'msg' => $e->getMessage(),
]);
throw $e;
}

// Handle race condition - unique violation
usleep(50000);
$existing = DB::selectOne('SELECT id FROM geo_locations WHERE name = ?', [$name]);
if ($existing) {
$this->geoLocationCache[$name] = $existing->id;

return $existing->id;
}
throw $e;
}
return app(GeoLocationService::class)->findOrCreateId(
$name,
$this->geoLocationCache,
allowGeocoding: false,
);
}

protected function findOrCreateEcosystem(string $name, ?int $geoLocationId = null): ?int
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Console\Commands\SubmissionsAutoProcess;

use App\Services\GeoLocationService;
use Illuminate\Console\Command;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
Expand Down Expand Up @@ -1110,36 +1111,11 @@ protected function findOrCreateGeoLocation(string $name): ?int
return null;
}

if (isset($this->geoLocationCache[$name])) {
return $this->geoLocationCache[$name];
}

$existing = DB::selectOne('SELECT id FROM geo_locations WHERE name = ?', [$name]);
if ($existing) {
$this->geoLocationCache[$name] = $existing->id;

return $existing->id;
}

try {
$id = DB::table('geo_locations')->insertGetId([
'name' => $name,
'created_at' => now(),
'updated_at' => now(),
]);
$this->geoLocationCache[$name] = $id;

return $id;
} catch (QueryException $e) {
usleep(50000);
$existing = DB::selectOne('SELECT id FROM geo_locations WHERE name = ?', [$name]);
if ($existing) {
$this->geoLocationCache[$name] = $existing->id;

return $existing->id;
}
throw $e;
}
return app(GeoLocationService::class)->findOrCreateId(
$name,
$this->geoLocationCache,
allowGeocoding: false,
);
}

protected function findOrCreateEcosystem(string $name, ?int $geoLocationId = null): ?int
Expand Down
3 changes: 2 additions & 1 deletion app/Filament/Dashboard/Resources/ReportResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use App\Models\Report;
use App\Models\ReportUser;
use App\Models\User;
use App\Services\GeoLocationService;
use Closure;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
Expand Down Expand Up @@ -1658,7 +1659,7 @@ public static function runSQLQueries(Report $record): void
if (! empty(self::$overall_changes['geo_location_changes']['add'])) {
$geoLocations = explode(',', self::$overall_changes['geo_location_changes']['add']);
foreach ($geoLocations as $newLocation) {
$geo_location = GeoLocation::firstOrCreate(['name' => $newLocation]);
$geo_location = app(GeoLocationService::class)->findOrCreate(trim($newLocation));
$molecule->auditAttach('geo_locations', $geo_location);
}
}
Expand Down
4 changes: 2 additions & 2 deletions app/Jobs/ImportEntry.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
namespace App\Jobs;

use App\Models\Citation;
use App\Models\GeoLocation;
use App\Models\Molecule;
use App\Models\Organism;
use App\Services\GeoLocationService;
use Exception;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
Expand Down Expand Up @@ -236,7 +236,7 @@ public function saveGeoLocationDetails($geo_location, $molecule)
$locations = explode('|', $this->entry->locations);
$i = 0;
foreach ($geo_locations as $geo_location) {
$geolocationModel = GeoLocation::firstOrCreate(['name' => $geo_location]);
$geolocationModel = app(GeoLocationService::class)->findOrCreate($geo_location, allowGeocoding: false);
$locationsNames = array_key_exists($i, $locations) ? $locations[$i] : '';
$molecule->geo_locations()->syncWithoutDetaching([$geolocationModel->id => ['locations' => $locationsNames]]);
$i = $i + 1;
Expand Down
9 changes: 5 additions & 4 deletions app/Jobs/ImportEntryAuto.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@

use App\Models\Citation;
use App\Models\Ecosystem;
use App\Models\GeoLocation;
use App\Models\Molecule;
use App\Models\MoleculeOrganism;
use App\Models\Organism;
use App\Models\SampleLocation;
use App\Services\GeoLocationService;
use Exception;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
Expand Down Expand Up @@ -253,9 +253,10 @@ public function saveOrganismDetails($organisms, $molecule)
foreach ($organism['locations'] as $location) {
$geo_location_ids = []; // Reset for each location

$geoLocationModel = GeoLocation::firstOrCreate([
'name' => $location['name'],
]);
$geoLocationModel = app(GeoLocationService::class)->findOrCreate(
$location['name'],
allowGeocoding: false,
);

// Connect organism to geo location directly
// $organismModel->geoLocations()->syncWithoutDetaching([$geoLocationModel->id]);
Expand Down
28 changes: 28 additions & 0 deletions app/Livewire/GeoGlobe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace App\Livewire;

use App\Services\GeoLocationGlobeStatsService;
use Livewire\Attributes\Layout;
use Livewire\Component;

#[Layout('layouts.guest')]
class GeoGlobe extends Component
{
public array $countryStats = [];

public array $totals = [];

public function mount(GeoLocationGlobeStatsService $statsService): void
{
$data = $statsService->getCountryStats();

$this->countryStats = $data['countries'];
$this->totals = $data['totals'];
}

public function render()
{
return view('livewire.geo-globe');
}
}
Loading
Loading