diff --git a/app/Allocator/Stand/SelectsStandsUsingStandardConditions.php b/app/Allocator/Stand/SelectsStandsUsingStandardConditions.php index 47247e938..4b6e5a848 100644 --- a/app/Allocator/Stand/SelectsStandsUsingStandardConditions.php +++ b/app/Allocator/Stand/SelectsStandsUsingStandardConditions.php @@ -4,6 +4,7 @@ use App\Models\Vatsim\NetworkAircraft; use Closure; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; @@ -73,6 +74,9 @@ private function standardConditionsStandQuery( ); } + /** + * Build stand ordering for a given aircraft/query mode. + */ private function orderByForStandsQuery( NetworkAircraft $aircraft, array $customOrders, @@ -93,12 +97,44 @@ private function orderByForStandsQuery( : $this->commonOrderByConditionsWithoutAssignmentPriorityForAircraft($aircraft); } + $nightTimeRemoteStandCondition = $this->nightTimeRemoteStandOrderCondition(); + + if ($nightTimeRemoteStandCondition !== null) { + $commonConditions = array_merge([$nightTimeRemoteStandCondition], $commonConditions); + } + return array_merge( $customOrders, $commonConditions ); } + /** + * Returns an optional SQL ORDER BY fragment that prefers remote stands overnight + * within the configured night window, or null when the bias should not be applied. + */ + private function nightTimeRemoteStandOrderCondition(): ?string + { + $config = config('stands.night_remote_stand_weighting'); + + // Feature flag: if disabled or misconfigured, do not apply any bias. + if (!(bool) ($config['enabled'] ?? false)) { + return null; + } + + // Check if current time is within the configured night window. + $hour = Carbon::now('Europe/London')->hour; + $startHour = (int) ($config['start_hour'] ?? 22); + $endHour = (int) ($config['end_hour'] ?? 6); + + // Supports both normal windows (e.g. 01 -> 05) and windows crossing midnight (e.g. 22 -> 06). + $isNightWindow = $startHour <= $endHour + ? $hour >= $startHour && $hour < $endHour + : $hour >= $startHour || $hour < $endHour; + + return $isNightWindow ? 'CASE WHEN stands.overnight_remote_preferred = 1 THEN 0 ELSE 1 END ASC' : null; + } + private function commonOrderByConditionsForAircraft(NetworkAircraft $aircraft): array { return $aircraft->cid === null diff --git a/app/Filament/Resources/StandResource.php b/app/Filament/Resources/StandResource.php index e7355fe35..57d35faea 100644 --- a/app/Filament/Resources/StandResource.php +++ b/app/Filament/Resources/StandResource.php @@ -160,6 +160,10 @@ public static function form(Form $form): Form ->maxValue(9999) ->default(100) ->required(), + Toggle::make('overnight_remote_preferred') + ->label(self::translateFormPath('overnight_remote_preferred.label')) + ->helperText(self::translateFormPath('overnight_remote_preferred.helper')) + ->default(false), TextInput::make('origin_slug') ->label(self::translateFormPath('origin_slug.label')) ->helperText(self::translateFormPath('origin_slug.helper')) @@ -203,6 +207,8 @@ public static function table(Table $table): Table ->label(self::translateTablePath('columns.priority')) ->sortable() ->searchable(), + Tables\Columns\BooleanColumn::make('overnight_remote_preferred') + ->label(self::translateTablePath('columns.overnight_remote_preferred')), ]) ->actions([ Tables\Actions\ViewAction::make(), diff --git a/app/Models/Stand/Stand.php b/app/Models/Stand/Stand.php index fbdca384d..a3da1b5f9 100644 --- a/app/Models/Stand/Stand.php +++ b/app/Models/Stand/Stand.php @@ -35,6 +35,7 @@ class Stand extends Model 'max_aircraft_length', 'max_aircraft_wingspan', 'assignment_priority', + 'overnight_remote_preferred', 'closed_at', 'isOpen', ]; @@ -46,6 +47,7 @@ class Stand extends Model 'max_aircraft_length' => 'double', 'max_aircraft_wingspan' => 'double', 'assignment_priority' => 'integer', + 'overnight_remote_preferred' => 'boolean', 'created_at' => 'datetime', 'updated_at' => 'datetime', 'closed_at' => 'datetime', diff --git a/config/stands.php b/config/stands.php index f35b2a0ed..1ef1253bd 100644 --- a/config/stands.php +++ b/config/stands.php @@ -3,4 +3,10 @@ return [ 'auto_allocate' => env('AUTO_ALLOCATE_STANDS', false), 'assignment_acars_message' => env('SEND_STAND_ACARS_MESSAGES', true), + 'night_remote_stand_weighting' => [ + 'enabled' => true, + // Europe/London time start and end hour (24h) for the overnight window (Automatic DST support). + 'start_hour' => 22, + 'end_hour' => 6, + ], ]; diff --git a/database/factories/Stand/StandFactory.php b/database/factories/Stand/StandFactory.php index a56e93dbc..1e040ddab 100644 --- a/database/factories/Stand/StandFactory.php +++ b/database/factories/Stand/StandFactory.php @@ -30,6 +30,7 @@ public function definition() 'longitude' => $this->faker->longitude(), 'aerodrome_reference_code' => 'F', // A380 'assignment_priority' => $this->faker->numberBetween(0, 1000), + 'overnight_remote_preferred' => false, ]; } diff --git a/database/migrations/2026_02_25_000001_add_overnight_remote_preferred_to_stands.php b/database/migrations/2026_02_25_000001_add_overnight_remote_preferred_to_stands.php new file mode 100644 index 000000000..44e8d5408 --- /dev/null +++ b/database/migrations/2026_02_25_000001_add_overnight_remote_preferred_to_stands.php @@ -0,0 +1,23 @@ +boolean('overnight_remote_preferred') + ->default(false) + ->after('assignment_priority'); + }); + } + + public function down(): void + { + Schema::table('stands', function (Blueprint $table): void { + $table->dropColumn('overnight_remote_preferred'); + }); + } +}; diff --git a/lang/en/stands/form.php b/lang/en/stands/form.php index 2204f37e9..0099a8d0a 100644 --- a/lang/en/stands/form.php +++ b/lang/en/stands/form.php @@ -48,6 +48,10 @@ 'label' => 'Allocation Priority', 'helper' => 'Global priority when assigning. Lower value is higher priority. Minimum 1, maximum 9999.', ], + 'overnight_remote_preferred' => [ + 'label' => 'Overnight Remote Preferred', + 'helper' => 'If enabled, this stand is preferred for overnight remote parking by the allocator.', + ], 'origin_slug' => [ 'label' => 'Origin Slug', 'helper' => 'Full or partial airfield ICAO to match arrival aircraft against. This is used when doing a "any flights from these origin airports" allocation and does not override airline-specific rules.', diff --git a/lang/en/stands/table.php b/lang/en/stands/table.php index e7bd8ffee..52e2b193f 100644 --- a/lang/en/stands/table.php +++ b/lang/en/stands/table.php @@ -11,6 +11,7 @@ 'airlines' => 'Airlines', 'used' => 'Used', 'priority' => 'Allocation Priority', + 'overnight_remote_preferred' => 'Overnight Remote Preferred', ], 'airlines' => [ 'description' => 'Airlines can be assigned to specific stands based on various parameters. See the allocation guide diff --git a/tests/app/Allocator/Stand/FallbackArrivalStandAllocatorTest.php b/tests/app/Allocator/Stand/FallbackArrivalStandAllocatorTest.php index 96dc777e3..a1ade0276 100644 --- a/tests/app/Allocator/Stand/FallbackArrivalStandAllocatorTest.php +++ b/tests/app/Allocator/Stand/FallbackArrivalStandAllocatorTest.php @@ -176,6 +176,87 @@ public function testItAssignsStandsInPriorityOrder() $this->assertEquals($highPriority->id, $assignment); } + public function testItPrefersRemoteStandsAtNight() + { + $airfield = Airfield::factory()->create(['code' => 'EXXA']); + + Carbon::setTestNow(Carbon::create(2024, 1, 1, 23, 0, 0, 'Europe/London')); + + try { + // Lower assignment priority = more desirable stand during daytime + Stand::create( + [ + 'airfield_id' => $airfield->id, + 'identifier' => 'T1', + 'latitude' => 54.65875500, + 'longitude' => -6.22258694, + 'aerodrome_reference_code' => 'C', + 'assignment_priority' => 1, + ] + ); + + $remoteStand = Stand::create( + [ + 'airfield_id' => $airfield->id, + 'identifier' => 'R1', + 'latitude' => 54.65875500, + 'longitude' => -6.22258694, + 'aerodrome_reference_code' => 'C', + 'assignment_priority' => 100, + 'overnight_remote_preferred' => true, + ] + ); + + $aircraft = $this->createAircraft('AEU252', 'B738', $airfield->code); + + $assignment = $this->allocator->allocate($aircraft); + + $this->assertEquals($remoteStand->id, $assignment); + } finally { + Carbon::setTestNow(); + } + } + + public function testItDoesNotPreferRemoteStandsOutsideNightHours() + { + $airfield = Airfield::factory()->create(['code' => 'EXXB']); + + Carbon::setTestNow(Carbon::create(2024, 1, 1, 12, 0, 0, 'Europe/London')); + + try { + $terminalStand = Stand::create( + [ + 'airfield_id' => $airfield->id, + 'identifier' => 'T1', + 'latitude' => 54.65875500, + 'longitude' => -6.22258694, + 'aerodrome_reference_code' => 'C', + 'assignment_priority' => 1, + ] + ); + + Stand::create( + [ + 'airfield_id' => $airfield->id, + 'identifier' => 'R1', + 'latitude' => 54.65875500, + 'longitude' => -6.22258694, + 'aerodrome_reference_code' => 'C', + 'assignment_priority' => 100, + 'overnight_remote_preferred' => true, + ] + ); + + $aircraft = $this->createAircraft('AEU252', 'B738', $airfield->code); + + $assignment = $this->allocator->allocate($aircraft); + + $this->assertEquals($terminalStand->id, $assignment); + } finally { + Carbon::setTestNow(); + } + } + public function testItOnlyAssignsNonCargoStands() { // Create a stand a cargo stand