Files
FlightsAPI/app/Http/Controllers/FlightImportController.php
T
2026-04-05 12:38:59 +10:00

346 lines
12 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Aircraft;
use App\Models\Airline;
use App\Models\Airport;
use App\Models\ImportedFlight;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class FlightImportController extends Controller
{
const array FLIGHT_CLASSES = [
0 => 'Please Select',
1 => 'Economy',
2 => 'Business',
3 => 'First',
4 => 'Premium Economy',
5 => 'Private',
];
const array SEAT_TYPES = [
0 => 'Please Select',
1 => 'Window',
2 => 'Middle',
3 => 'Aisle',
];
const array FLIGHT_REASONS = [
0 => 'Please Select',
1 => 'Pleasure',
2 => 'Business',
3 => 'Crew',
4 => 'Other',
];
private function formatTime(?string $time): string
{
if (!$time) return '00:00';
[$hours, $minutes] = explode(':', $time);
return str_pad($hours, 2, '0', STR_PAD_LEFT) . ':' . $minutes;
}
public function getPossibleAircraft(string $aircraftQuery) {
preg_match('/\((\w+)\)/', $aircraftQuery, $matches);
$designator = $matches[1] ?? null;
if(!$designator){
$aircraft = [];
} else {
$aircraft = Aircraft::when($designator, fn($query) => $query->where('designator', 'ilike', $designator))
->orderBy('model_full_name')
->limit(10)
->get(['id', 'manufacturer_code', 'model_full_name', 'designator'])
->map(fn($a) => [
'value' => $a->id,
'title' => "{$a->manufacturer_code} {$a->model_full_name} ({$a->designator})",
])
->values()
->toArray();
}
return $aircraft;
}
public function getPossibleAirports(string $airportQuery) {
preg_match('/\((\w{3})\/(\w{4})\)/', $airportQuery, $matches);
$iata = $matches[1] ?? null;
$icao = $matches[2] ?? null;
if (!$iata && !$icao) {
return [];
}
$airports = Airport::with('region.country')
->where(function ($q) use ($iata, $icao) {
$q->where('iata_code', 'ilike', $iata)
->orWhere('icao_code', 'ilike', $icao);
})
->orderByRaw("
CASE
WHEN iata_code = ? AND icao_code = ? THEN 0
WHEN iata_code = ? THEN 1
WHEN icao_code = ? THEN 2
ELSE 3
END
", [$iata, $icao, $iata, $icao])
->limit(10)
->get(['id', 'name', 'municipality', 'iata_code', 'icao_code', 'region_id'])
->map(fn($a) => [
'value' => $a->id,
'title' => "{$a->name} ({$a->iata_code}/{$a->icao_code})",
'country_code' => strtolower($a?->region->country->code ?? ''),
])
->values()
->toArray();
return $airports;
}
public function getPossibleAirlines(string $airlineQuery) {
preg_match('/\((\w{2,3})\/(\w{3,4})\)/', $airlineQuery, $matches);
$iata = $matches[1] ?? null;
$icao = $matches[2] ?? null;
$airlines = Airline::when($iata || $icao, function ($query) use ($iata, $icao) {
$query->orderByRaw("
CASE
WHEN \"IATA_code\" = ? AND \"ICAO_code\" = ? THEN 0
WHEN \"IATA_code\" = ? THEN 1
WHEN \"ICAO_code\" = ? THEN 2
ELSE 3
END
", [$iata, $icao, $iata, $icao])
->where(function ($q) use ($iata, $icao) {
$q->where('IATA_code', $iata)
->orWhere('ICAO_code', $icao);
});
})
->orderByDesc('active')
->limit(10)
->get(['id', 'name', 'IATA_code', 'ICAO_code'])
->map(fn($a) => [
'value' => $a->id,
'title' => "{$a->name} ({$a->IATA_code}/{$a->ICAO_code})",
])
->values()
->toArray();
return $airlines;
}
public function reconcile(Request $request)
{
$user = Auth::user();
$flightToReconcile = ImportedFlight::where('user_id', $user->id)->orderBy('id')->first();
if (!$flightToReconcile) {
return null;
}
$date = null;
if ($flightToReconcile->date) {
$date = Carbon::createFromFormat('Y-m-d', $flightToReconcile->date)->format('Y-m-d');
}
return [
'flight_classes' => collect(self::FLIGHT_CLASSES)->map(fn($title, $value) => [
'value' => $value,
'title' => $title,
])->values(),
'flight_reasons' => collect(self::FLIGHT_REASONS)->map(fn($title, $value) => [
'value' => $value,
'title' => $title,
])->values(),
'seat_types' => collect(self::SEAT_TYPES)->map(fn($title, $value) => [
'value' => $value,
'title' => $title,
])->values(),
'flight_number' => $flightToReconcile->flight_number ?? '',
'date' => $date ?? '',
'dep_time' => $this->formatTime($flightToReconcile->dep_time),
'arr_time' => $this->formatTime($flightToReconcile->arr_time),
'duration' => $this->formatTime($flightToReconcile->duration),
'registration' => $flightToReconcile->registration ?? '',
'note' => $flightToReconcile->note ?? '',
'seat_number' => $flightToReconcile->seat_number ?? '',
'flight_class' => $flightToReconcile->flight_class ?? '',
'seat_type' => $flightToReconcile->seat_type ?? '',
'flight_reason' => $flightToReconcile->flight_reason ?? '',
'airline_options' => $this->getPossibleAirlines($flightToReconcile->airline ?? ''),
'to_options' => $this->getPossibleAirports($flightToReconcile->to ?? ''),
'from_options' => $this->getPossibleAirports($flightToReconcile->from ?? ''),
'aircraft_options' => $this->getPossibleAircraft($flightToReconcile->aircraft ?? ''),
];
}
public function save(Request $request){
$validated = $request->validate([
'date' => 'required|date',
'flight_number' => 'required|string',
'from_id' => 'required|integer|exists:airports,id',
'to_id' => 'required|integer|exists:airports,id',
'dep_time' => 'nullable|date_format:H:i',
'arr_time' => 'nullable|date_format:H:i',
'duration' => 'required|date_format:H:i',
'airline_id' => 'nullable|integer|exists:airlines,id',
'aircraft_id' => 'nullable|integer|exists:aircraft,id',
'registration' => 'nullable|string',
'seat_number' => 'nullable|string',
'seat_type_id' => 'nullable|integer',
'flight_class_id' => 'nullable|integer',
'flight_reason_id' => 'nullable|integer',
'note' => 'nullable|string',
],[
'from_id.required' => 'Please select a departure airport.',
'to_id.required' => 'Please select an arrival airport.',
'dep_time.date_format' => 'Departure time must be in HH:MM format, i.e: 01:25',
'arr_time.date_format' => 'Arrival time must be in HH:MM format, i.e: 12:25',
'duration.date_format' => 'Must be in HH:MM format, e.g: 03:37',
'duration.required' => 'A duration is required to be able to accurately calculate the arrival date',
]);
}
private function validateCsvFormat(string $path): ?string
{
$handle = fopen($path, 'r');
// Must have empty first line
$firstLine = fgetcsv($handle);
if (!empty(array_filter($firstLine))) {
fclose($handle);
return 'CSV must match the MyFlightRadar24 export format.';
}
// Validate headers
$expectedHeaders = [
'date', 'flight_number', 'from', 'to', 'dep_time', 'arr_time',
'duration', 'airline', 'aircraft', 'registration', 'seat_number',
'seat_type', 'flight_class', 'flight_reason', 'note',
'dep_id', 'arr_id', 'airline_id', 'aircraft_id'
];
$headers = array_map(
fn($h) => strtolower(trim(str_replace([' ', '"'], ['_', ''], $h))),
fgetcsv($handle)
);
if ($headers !== $expectedHeaders) {
fclose($handle);
return 'CSV headers do not match the expected format.';
}
// Validate data rows
$row = 1;
while (($data = fgetcsv($handle)) !== false) {
$row++;
if (count($data) !== count($expectedHeaders)) {
fclose($handle);
return "Row {$row} has the wrong number of columns.";
}
$combined = array_combine($expectedHeaders, $data);
if (empty($combined['date']) || empty($combined['from']) || empty($combined['to'])) {
fclose($handle);
return "Row {$row} is missing a required field (date, from, or to).";
}
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $combined['date'])) {
fclose($handle);
return "Row {$row} has an invalid date format. Expected YYYY-MM-DD, got '{$combined['date']}'.";
}
}
fclose($handle);
return null;
}
public function store(Request $request)
{
try {
$request->validate([
'csv' => ['required', 'file', 'mimes:csv,txt', 'max:10240'],
]);
$path = $request->file('csv')->getRealPath();
$validationError = $this->validateCsvFormat($path);
if ($validationError) {
return response()->json(['message' => $validationError], 422);
}
$handle = fopen($path, 'r');
fgetcsv($handle);
// Read and normalise header row
$headers = array_map(
fn($h) => strtolower(trim(str_replace(' ', '_', $h))),
fgetcsv($handle)
);
\Log::debug('CSV headers', $headers);
$firstRow = fgetcsv($handle);
\Log::debug('First row', $firstRow ?? ['empty']);
\Log::debug('Header count: ' . count($headers) . ', Row count: ' . count($firstRow ?? []));
$map = [
'date' => 'date',
'flight_number' => 'flight_number',
'from' => 'from',
'to' => 'to',
'dep_time' => 'dep_time',
'arr_time' => 'arr_time',
'duration' => 'duration',
'airline' => 'airline',
'aircraft' => 'aircraft',
'registration' => 'registration',
'seat_number' => 'seat_number',
'seat_type' => 'seat_type',
'flight_class' => 'flight_class',
'flight_reason' => 'flight_reason',
'note' => 'note',
];
$userId = Auth::id();
$imported = 0;
while (($row = fgetcsv($handle)) !== false) {
if (count($row) !== count($headers)) {
continue; // skip malformed rows
}
$raw = array_combine($headers, $row);
$data = ['user_id' => $userId];
foreach ($map as $csvKey => $column) {
$data[$column] = $raw[$csvKey] ?? null;
}
ImportedFlight::create($data);
$imported++;
}
fclose($handle);
return response()->json(['imported' => $imported]);
} catch (\Exception $e) {
return response()->json(['message' => $e->getMessage()], 500);
}
}
}