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; $sortOverrides = [ 'B788' => "CASE WHEN model_full_name ILIKE '%BBJ%' THEN 1 ELSE 0 END", 'B789' => "CASE WHEN model_full_name ILIKE '%BBJ%' THEN 1 ELSE 0 END", ]; if(!$designator){ $aircraft = []; } else { $aircraft = Aircraft::when($designator, fn($query) => $query->where('designator', 'ilike', $designator)) ->when( isset($sortOverrides[$designator]), fn($q) => $q->orderByRaw($sortOverrides[$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) ->orderBy('id') ->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' => 'required|integer|exists:seat_types,id', 'flight_class_id' => 'required|integer|exists:flight_classes,id', 'flight_reason_id' => 'required|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); } } }