Files
FlightsAPI/app/Http/Controllers/FlightImportController.php
T
2026-04-05 18:55:39 +10:00

387 lines
14 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\Aircraft;
use App\Models\Airline;
use App\Models\Airport;
use App\Models\FlightClass;
use App\Models\FlightReason;
use App\Models\ImportedFlight;
use App\Models\SeatType;
use App\Models\UserFlight;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class FlightImportController extends Controller
{
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;
}
private function selectOptions($model): array
{
return $model::orderBy('id')
->get(['id', 'name'])
->map(fn($item) => ['value' => $item->id, 'title' => $item->name])
->values()
->toArray();
}
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->municipality} / {$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;
if(!$iata && !$icao){
return [];
}
$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('date', 'asc')->first();
if (!$flightToReconcile) {
return null;
}
$date = null;
if ($flightToReconcile->date) {
$date = Carbon::createFromFormat('Y-m-d', $flightToReconcile->date)->format('Y-m-d');
}
return [
'imported_flight_id' => $flightToReconcile->id,
'flight_classes' => $this->selectOptions(FlightClass::class),
'flight_reasons' => $this->selectOptions(FlightReason::class),
'seat_types' => $this->selectOptions(SeatType::class),
'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',
'imported_flight_id' => 'required|exists:imported_flights,id',
'flight_number' => 'nullable|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|exists:seat_types,id',
'flight_class_id' => 'nullable|integer|exists:flight_classes,id',
'flight_reason_id' => 'nullable|integer|exists:flight_reasons,id',
'note' => 'nullable|string',
], [
'imported_flight_id.required' => 'The flight you are trying to reconcile needs to be reimported or refreshed.',
'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',
]);
$user = Auth::user();
$departureAirport = Airport::find($validated['from_id']);
$arrivalAirport = Airport::find($validated['to_id']);
// Parse departure in local airport timezone, then convert to UTC
$depTime = $validated['dep_time'] ?? '00:00';
$departure = Carbon::createFromFormat(
'Y-m-d H:i',
$validated['date'] . ' ' . $depTime,
$departureAirport->timezone
)->utc();
// Calculate duration-based arrival in UTC
[$durationHours, $durationMinutes] = explode(':', $validated['duration']);
$durationArrival = $departure->copy()
->addHours((int) $durationHours)
->addMinutes((int) $durationMinutes);
// If arrival time provided, parse it in arrival airport timezone and convert to UTC
if (!empty($validated['arr_time'])) {
$arrival = Carbon::createFromFormat(
'Y-m-d H:i',
$validated['date'] . ' ' . $validated['arr_time'],
$arrivalAirport->timezone
)->utc();
// If arrival is not after departure, fall back to duration-based arrival
if ($arrival->lte($departure)) {
$arrival = $durationArrival;
}
} else {
$arrival = $durationArrival;
}
UserFlight::create([
'user_id' => $user->id,
'departure_date' => $departure,
'arrival_date' => $arrival,
'flight_number' => $validated['flight_number'],
'departure_airport_id' => $validated['from_id'],
'arrival_airport_id' => $validated['to_id'],
'airline_id' => $validated['airline_id'] ?? null,
'aircraft_id' => $validated['aircraft_id'] ?? null,
'aircraft_registration' => $validated['registration'] ?? null,
'seat_number' => $validated['seat_number'] ?? null,
'seat_type_id' => $validated['seat_type_id'] ?? 0,
'flight_class_id' => $validated['flight_class_id'] ?? 0,
'flight_reason_id' => $validated['flight_reason_id'] ?? 0,
'note' => $validated['note'] ?? null,
]);
ImportedFlight::destroy($validated['imported_flight_id']);
return to_route('reconcile');
}
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);
}
}
}