diff --git a/app/Aircraft.php b/app/Aircraft.php new file mode 100644 index 000000000..c1829c85d --- /dev/null +++ b/app/Aircraft.php @@ -0,0 +1,29 @@ +exists(self::FILENAME)) { + self::$data = Collection::fromJson(Storage::disk('local')->get(self::FILENAME)); + } + self::$initialized = true; + } + + public static function fetch($acid) { + $ac = self::$data->where('ac_type', $acid)->first(); + return (is_null($ac)) ? null : (object) $ac; + } +} +Aircraft::init(); diff --git a/app/Console/Commands/UpdateFAA.php b/app/Console/Commands/UpdateFAA.php new file mode 100644 index 000000000..5420b5b6b --- /dev/null +++ b/app/Console/Commands/UpdateFAA.php @@ -0,0 +1,149 @@ + 'PRD', + 'model' => 'App\PreferredRoute', + 'file' => 'prefroutes_db.csv' + ] + ]; + private ProgressIndicator $progressIndicator; + private $path; + + /** + * Execute the console command. + */ + public function handle() { + $this->info('Updating FAA-specific datafiles'); + $this->path = Storage::disk('local')->path('private/'); + $this->setup_progress_indicator(); + $this->update_routes(); + } + + private function update_routes(): void { + foreach ($this->routes as $route) { + $route = (object) $route; + $this->progressIndicator->start("Fetching $route->name info from FAA server..."); + $this->call_api(SELF::FAA_DATA_URL . $route->file, $this->path, strtolower($route->name) . '_new.csv'); + $this->progressIndicator->finish("Fetching $route->name info from FAA server... done"); + + // Update table + $this->progressIndicator->start("Updating $route->name table..."); + if (($handle = fopen($this->path . strtolower($route->name) . '_new.csv', "r")) !== false) { + $route->model::truncate(); + while (($rte_line = fgetcsv($handle, null, ",")) !== false) { + if (!is_array($rte_line)) { + continue; + } + $bom = "\xef\xbb\xbf"; + if (substr($rte_line[0], 0, 3) === $bom) { + $rte_line[0] = substr($rte_line[0], 3); + } + if ($rte_line[0] == 'RCode' || $rte_line[0] == 'Orig') { // It's the header line, skip it + continue; + } + if ($route->name == 'CDR') { + $load_arr = [ + 'route_code' => $rte_line[0], + 'origin' => $rte_line[1], + 'destination' => $rte_line[2], + 'departure_fix' => substr($rte_line[3], 0, 5), // Enforce 5 characters + 'route_string' => $rte_line[4], + 'departure_center' => $rte_line[5], + 'arrival_center' => $rte_line[6], + 'transit_centers' => $rte_line[7], + 'coordination_required' => $rte_line[8], + 'play' => $rte_line[9], + 'navigation_equipment' => $rte_line[10] + ]; + } else { + $load_arr = [ + 'orig' => $rte_line[0], + 'route_string' => $rte_line[1], + 'dest' => $rte_line[2], + 'hours1' => $rte_line[3], + 'hours2' => $rte_line[4], + 'hours3' => $rte_line[5], + 'type' => $rte_line[6], + 'area' => $rte_line[7], + 'altitude' => (is_numeric($rte_line[8])) ? $rte_line[8] : null, + 'aircraft' => $rte_line[9], + 'direction' => $rte_line[10], + 'seq' => $rte_line[11], + 'dcntr' => $rte_line[12], + 'acntr' => $rte_line[13] + ]; + } + $route->model::create($load_arr); + $this->progressIndicator->advance(); + } + fclose($handle); + } + $this->progressIndicator->finish("Updating $route->name table... done"); + + // Remove, rename file + $this->info("Removing unused local $route->name files"); + if (file_exists($this->path . $route->name . '.csv')) { + unlink($this->path . $route->name . '.csv'); + $this->info("- Deleted $route->name.csv"); + } + if (file_exists($this->path . $route->name . '_new.csv')) { + rename($this->path . $route->name . '_new.csv', $this->path . $route->name . '.csv'); + } + $this->info("$route->name file cleanup complete"); + } + } + + private function call_api($url, $path = null, $write_file = null): string|null { + $progress = $this->progressIndicator; + $write_to_filename = (is_null($write_file)) ? basename($url) : $write_file; + $client = new Client(); + $response = $client->get($url, [ + 'config' => [ + 'curl' => [ + 'CURLOPT_RETURNTRANSFER' => true, + 'CURLOPT_CONNECTTIMEOUT' => 3, + 'CURLOPT_ENCODING' => 'gzip', + 'CURLOPT_SSL_VERIFYPEER' => false + ] + ], + 'progress' => function () use ($progress) { + $progress->advance(); + }, + 'sink' => $path . $write_to_filename + ]); + if ($response->getStatusCode() == 200) { + return $response->getBody(); + } + return null; + } + + private function setup_progress_indicator(): void { + $output = new ConsoleOutput; + $this->progressIndicator = new ProgressIndicator($output); + } +} diff --git a/app/Exports/RealopsExport.php b/app/Exports/RealopsExport.php index 2cf97f97f..5270cb67b 100644 --- a/app/Exports/RealopsExport.php +++ b/app/Exports/RealopsExport.php @@ -22,6 +22,7 @@ public function headings(): array { 'Point of Arrival', 'Arrival Time', 'Gate', + 'Aircraft Type', 'Pilot Name', 'Pilot CID', 'Pilot Email' @@ -44,6 +45,7 @@ public function map($flight): array { $flight->arr_airport, $flight->est_time_enroute, $flight->gate, + $flight->aircraft_type, $pilot->full_name, $flight->assigned_pilot_id, $pilot->email diff --git a/app/Http/Controllers/RealopsController.php b/app/Http/Controllers/RealopsController.php index d34fe00e1..e96c4c739 100644 --- a/app/Http/Controllers/RealopsController.php +++ b/app/Http/Controllers/RealopsController.php @@ -18,11 +18,13 @@ class RealopsController extends Controller { public function index(Request $request) { $airport_filter = $request->get('airport_filter'); $flightno_filter = $request->get('flightno_filter'); + $actype_filter = $request->get('actype_filter'); $date_filter = $request->get('date_filter'); $time_filter = $request->get('time_filter'); $flights = RealopsFlight::where('flight_number', 'like', '%' . $flightno_filter . '%') ->orWhere('callsign', 'like', '%' . $flightno_filter . '%') + ->orWhere('aircraft_type', 'like', '%' . $actype_filter . '%') ->when(! is_null($time_filter), function ($query) use ($time_filter) { $times = $this->timeBetween($time_filter, 15, 45); $query->whereTime('dep_time', ">=", Carbon::parse($times[0])) @@ -39,7 +41,7 @@ public function index(Request $request) { ->orderBy('dep_time', 'ASC') ->paginate(20); - return view('site.realops')->with('flights', $flights)->with('airport_filter', $airport_filter)->with('flightno_filter', $flightno_filter)->with('date_filter', $date_filter)->with('time_filter', $time_filter); + return view('site.realops', compact('flights', 'airport_filter', 'flightno_filter', 'actype_filter', 'date_filter', 'time_filter')); } public function bid($id) { @@ -149,6 +151,7 @@ public function createFlight(Request $request) { $flight->arr_airport = $request->input('arr_airport'); $flight->est_time_enroute = $request->input('est_time_enroute'); $flight->gate = $request->input('gate'); + $flight->aircraft_type = $request->input('aircraft_type'); $flight->save(); return redirect('/dashboard/admin/realops')->with(SessionVariables::SUCCESS->value, 'That flight was created successfully'); @@ -188,6 +191,7 @@ public function editFlight(Request $request, $id) { $flight->arr_airport = $request->input('arr_airport'); $flight->est_time_enroute = $request->input('est_time_enroute'); $flight->gate = $request->input('gate'); + $flight->aircraft_type = $request->input('aircraft_type'); $flight->save(); $pilot = $flight->assigned_pilot; @@ -205,9 +209,6 @@ public function bulkUploadFlights(Request $request) { ]); try { - // what is this for? - // this doesn't do anything - //$contents = file_get_contents($request->file('file')->getRealPath()); Excel::import(new RealopsFlightImporter, request()->file('file')); } catch (\Maatwebsite\Excel\Validators\ValidationException $e) { $failures = $e->failures(); @@ -215,7 +216,6 @@ public function bulkUploadFlights(Request $request) { $errors = ""; foreach ($failures as $failure) { - // L21#0 Blah blah blah (value: 'fbalsdhj') Log::info($failure); $errors = $errors.' L'.$failure->row().'#'.$failure->attribute().': '.join(',', $failure->errors()).' ('.$failure->values()[$failure->attribute()].')'; } diff --git a/app/Importers/RealopsFlightImporter.php b/app/Importers/RealopsFlightImporter.php index a0c12df9f..8613a8b4b 100644 --- a/app/Importers/RealopsFlightImporter.php +++ b/app/Importers/RealopsFlightImporter.php @@ -17,6 +17,7 @@ public function model($row) { } $est_time_enroute = null; $gate = null; + $aircraft_type = null; if (count($row) > 6) { $est_time_enroute = $row[6]; @@ -26,6 +27,10 @@ public function model($row) { $gate = $row[7]; } + if (count($row) > 8) { + $aircraft_type = $row[8]; + } + $flight = new RealopsFlight; $flight->flight_number = $row[0]; $flight->callsign = $row[1]; @@ -35,6 +40,7 @@ public function model($row) { $flight->arr_airport = $row[5]; $flight->est_time_enroute = $est_time_enroute; $flight->gate = $gate; + $flight->aircraft_type = $aircraft_type; return $flight; } diff --git a/app/PreferredRoute.php b/app/PreferredRoute.php new file mode 100644 index 000000000..cdb46a44b --- /dev/null +++ b/app/PreferredRoute.php @@ -0,0 +1,35 @@ +where('dest', substr($arrival, 1))->where(function ($query) use ($ac_type) { + $query->where('aircraft', 'LIKE', '%' . strtoupper($ac_type) . '%')->orWhere('aircraft', ''); + })->first(); + if (!$routes) { + return ''; + } + $clean_route = self::remove_origin_destination_points($routes->route_string, $departure, $arrival); + return $clean_route; + } + + private static function remove_origin_destination_points($route_string, $departure, $arrival): string { + $route = explode(' ', $route_string); + if (!is_array($route)) { + return ''; + } + if ($route[0] == substr($departure, 1)) { + unset($route[0]); + } + if (end($route) == substr($arrival, 1)) { + array_pop($route); + } + return implode(' ', $route); + } +} diff --git a/app/RealopsFlight.php b/app/RealopsFlight.php index cfe96cc8a..01411aa9a 100644 --- a/app/RealopsFlight.php +++ b/app/RealopsFlight.php @@ -9,6 +9,14 @@ class RealopsFlight extends Model { protected $table = 'realops_flights'; + public function getFlightIdAttribute(): string { + return (!is_null($this->callsign)) ? $this->callsign : $this->flight_number; + } + + public function getAirlineAttribute(): string { + return substr($this->flight_id, 0, 3); + } + public function getAssignedPilotAttribute() { return RealopsPilot::find($this->assigned_pilot_id); } @@ -44,6 +52,41 @@ private function formatTime($time) { return $time_split[0] . ':' . $time_split[1]; } + private function timePart($time, $part): null|int { + $time_parts = explode(':', $time); + if (count($time_parts) == 0) { + return null; + } elseif ($part == 'h') { + return (int) $time_parts[0]; + } elseif ($part == 'm') { + return (int) $time_parts[1]; + } + return null; + } + + private function eta(): string { + $etd = explode(':', $this->dep_time); + $ete = explode(':', $this->est_time_enroute); + if (count($etd) == 0 || count($ete) == 0) { + return '0000'; + } + $hours = $etd[0] + $ete[0]; + $minutes = $etd[1] + $ete[1]; + if ($minutes > 59) { + $minutes -= 60; + $hours++; + } + if ($hours > 23) { + $hours -= 24; + } + return str_pad($hours, 2, '0', STR_PAD_LEFT) . str_pad($minutes, 2, '0', STR_PAD_LEFT); + } + + private function ete(): string { + $ete = explode(':', $this->est_time_enroute); + return str_pad($ete[0], 2, '0', STR_PAD_LEFT) . str_pad($ete[1], 2, '0', STR_PAD_LEFT); + } + public function getImageDirectory() { $flight_id = (!is_null($this->callsign)) ? $this->callsign : $this->flight_number; $airline = strtoupper(substr($flight_id, 0, 3)); @@ -53,4 +96,62 @@ public function getImageDirectory() { } return Vite::image('airline_logos/default.png'); } + + // ICAO flight plan reference: https://www.faa.gov/documentLibrary/media/Form/7233-4_02-07-24.pdf + public function getIcaoFlightplanAttribute(): string { + $icao_string = 'FPL-' . $this->flight_number . '-IS '; + $icao_string .= '-' . $this->aircraft_type; + $ac = Aircraft::fetch($this->aircraft_type); + if ($ac) { + $icao_string .= '/' . $ac->icao_wtc . '-' . $ac->equipment . '/' . $ac->transponder; + } + $icao_string .= ' -' . $this->dep_airport . str_pad($this->timePart($this->dep_time, 'h'), 2, '0', STR_PAD_LEFT) . str_pad($this->timePart($this->dep_time, 'm'), 2, '0', STR_PAD_LEFT); + $icao_string .= ' -' . PreferredRoute::routeLookup($this->dep_airport, $this->arr_airport); + $icao_string .= ' -' . $this->arr_airport . $this->ete(); + $icao_string .= ' -DOF/' . Carbon::parse($this->flight_date)->format('ymd') . ' OPR/' . $this->airline; + if ($ac) { + $icao_string .= ' PBN/' . $ac->pbn . ' PER/' . $ac->perf_cat; + } + return '(' . $icao_string . ')'; + } + + // SimBrief reference: https://developers.navigraph.com/docs/simbrief/using-the-api#api-parameters + public function getSimbriefParamsAttribute(): string { + $pilot = RealopsPilot::find($this->assigned_pilot_id); + $params = [ + 'airline' => $this->airline, + 'fltnum' => substr($this->flight_number, 3), + 'type' => $this->aircraft_type, + 'orig' => $this->dep_airport, + 'dest' => $this->arr_airport, + 'date' => Carbon::parse($this->flight_date)->format('dMY'), + 'deph' => $this->timePart($this->dep_time, 'h'), + 'depm' => $this->timePart($this->dep_time, 'm'), + 'route' => PreferredRoute::routeLookup($this->dep_airport, $this->arr_airport), + 'steh' => $this->timePart($this->est_time_enroute, 'h'), + 'stem' => $this->timePart($this->est_time_enroute, 'm'), + 'callsign' => $this->callsign, + 'cpt' => $pilot->full_name, + 'pid' => $pilot->id, + 'acdata' => null + ]; + $ac = Aircraft::fetch($this->aircraft_type); + if ($ac) { + $ac_data = [ + "pbn" => $ac->pbn, + "dof" => Carbon::parse($this->flight_date)->format('ymd'), + "opr" => $this->airline, + "per" => $ac->perf_cat, + "cat" => $ac->icao_wtc, + "equip" => $ac->equipment, + "transponder" => $ac->transponder + ]; + $params['acdata'] = json_encode((object) $ac_data); + } + + if (count($params) == 0) { + return ''; + } + return http_build_query($params); + } } diff --git a/database/migrations/2025_11_24_145500_realops_flight_add_actype.php b/database/migrations/2025_11_24_145500_realops_flight_add_actype.php new file mode 100644 index 000000000..a4e78cff4 --- /dev/null +++ b/database/migrations/2025_11_24_145500_realops_flight_add_actype.php @@ -0,0 +1,24 @@ +string('aircraft_type')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void { + Schema::table('realops_flights', function ($table) { + $table->dropColumn('aircraft_type'); + }); + } +}; diff --git a/database/migrations/2025_11_25_083600_create_prd_routes_table.php b/database/migrations/2025_11_25_083600_create_prd_routes_table.php new file mode 100644 index 000000000..a6418e86a --- /dev/null +++ b/database/migrations/2025_11_25_083600_create_prd_routes_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('orig', length: 5); + $table->text('route_string'); + $table->string('dest', length: 5); + $table->string('hours1', length: 50); + $table->string('hours2', length: 50); + $table->string('hours3', length: 50); + $table->string('type', length: 4); + $table->string('area', length: 100); + $table->integer('altitude')->nullable(); + $table->string('aircraft', length: 255); + $table->string('direction', length: 255); + $table->integer('seq'); + $table->string('dcntr', length: 4); + $table->string('acntr', length: 4); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void { + Schema::dropIfExists('prd_route'); + } +}; diff --git a/resources/assets/img/navigraph-icon.png b/resources/assets/img/navigraph-icon.png new file mode 100644 index 000000000..c70c735ed Binary files /dev/null and b/resources/assets/img/navigraph-icon.png differ diff --git a/resources/assets/img/vatsim-icon.png b/resources/assets/img/vatsim-icon.png new file mode 100644 index 000000000..44a354e2b Binary files /dev/null and b/resources/assets/img/vatsim-icon.png differ diff --git a/resources/views/dashboard/admin/realops/create.blade.php b/resources/views/dashboard/admin/realops/create.blade.php index fc4594730..d56f325c5 100644 --- a/resources/views/dashboard/admin/realops/create.blade.php +++ b/resources/views/dashboard/admin/realops/create.blade.php @@ -58,8 +58,8 @@ {{ html()->text('arr_airport', null)->class(['form-control'])->placeholder('Required') }}
Assigned to You - @unlesstoggle($FeatureToggles::REALOPS_BIDDING) - Cancel Bid - @endtoggle
@elseAssigned
@@ -108,17 +110,17 @@Bidding Closed, No Assignment
@endif - @if(auth()->guard('realops')->check() && toggleEnabled($FeatureToggles::REALOPS_BIDDING)) -