option('file'); if ($file === null || ! is_readable($file)) { $this->error('--file is required and must be a readable path to an ONSPD CSV.'); return self::FAILURE; } $handle = fopen($file, 'r'); if ($handle === false) { $this->error("Unable to open {$file}."); return self::FAILURE; } $header = fgetcsv($handle); if ($header === false) { $this->error('CSV is empty.'); fclose($handle); return self::FAILURE; } $headerCounts = array_count_values(array_map('strtolower', $header)); $columns = array_change_key_case(array_flip($header), CASE_LOWER); $pcdColumn = null; foreach (['pcd', 'pcds', 'pcd7', 'pcd8'] as $candidate) { if (isset($columns[$candidate])) { $pcdColumn = $candidate; break; } } if ($pcdColumn === null) { $this->error('Missing required postcode column (expected one of: pcd, pcds, pcd7, pcd8).'); fclose($handle); return self::FAILURE; } foreach ([$pcdColumn, 'lat', 'long'] as $required) { if (($headerCounts[$required] ?? 0) > 1) { $this->error("Column '{$required}' appears more than once — refusing to import."); fclose($handle); return self::FAILURE; } } foreach (['lat', 'long'] as $required) { if (! isset($columns[$required])) { $this->error("Missing required column '{$required}'."); fclose($handle); return self::FAILURE; } } $hasDoterm = isset($columns['doterm']); // Stream into a staging table first. Only swap into the live // postcodes / outcodes tables once the full CSV has been consumed — // a mid-stream failure leaves production data untouched. Schema::dropIfExists('postcodes_staging'); Schema::create('postcodes_staging', function (Blueprint $table): void { $table->string('postcode', 7); $table->string('outcode', 4); $table->decimal('lat', 10, 7); $table->decimal('lng', 10, 7); }); $buffer = []; $imported = 0; try { while (($row = fgetcsv($handle)) !== false) { if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') { continue; } $lat = trim((string) ($row[$columns['lat']] ?? '')); $lng = trim((string) ($row[$columns['long']] ?? '')); if ($lat === '' || $lng === '') { continue; } $pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns[$pcdColumn]])); if ($pcd === '' || strlen($pcd) < 5) { continue; } $buffer[] = [ 'postcode' => $pcd, 'outcode' => substr($pcd, 0, strlen($pcd) - 3), 'lat' => (float) $lat, 'lng' => (float) $lng, ]; if (count($buffer) >= self::CHUNK_SIZE) { DB::table('postcodes_staging')->insert($buffer); $imported += count($buffer); $buffer = []; } } if ($buffer !== []) { DB::table('postcodes_staging')->insert($buffer); $imported += count($buffer); } // Swap: empty live tables, copy from staging, derive outcodes. DB::table('outcodes')->truncate(); DB::table('postcodes')->truncate(); DB::statement( 'INSERT INTO postcodes (postcode, outcode, lat, lng) SELECT postcode, outcode, lat, lng FROM postcodes_staging' ); DB::statement( 'INSERT INTO outcodes (outcode, lat, lng) SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode' ); } catch (Throwable $e) { $this->error('Import failed — live tables left untouched: '.$e->getMessage()); return self::FAILURE; } finally { fclose($handle); Schema::dropIfExists('postcodes_staging'); } $this->info("Imported {$imported} postcodes."); $this->info('Derived '.DB::table('outcodes')->count().' outcode centroids.'); return self::SUCCESS; } }